From 3edfb6ad20f5c64da0622c111411163dd02ed56d Mon Sep 17 00:00:00 2001
From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com>
Date: Sat, 29 Aug 2026 02:56:37 -0700
Subject: [PATCH] fix(profile): show agent display labels
---
.../install.preserves-displayname.test.js | 44 +++++++++++
.../unit/controllers/userController.test.js | 22 ++++++
.../unit/routes/agentProfile.avatar.test.js | 1 +
.../routes/agentProfile.displayLabel.test.js | 75 +++++++++++++++++++
backend/controllers/userController.ts | 10 +++
backend/routes/agentProfile.ts | 39 +++++++++-
backend/routes/registry/install.ts | 9 ++-
frontend/src/components/UserProfile.test.tsx | 23 +++++-
frontend/src/components/UserProfile.tsx | 10 ++-
9 files changed, 221 insertions(+), 12 deletions(-)
create mode 100644 backend/__tests__/unit/routes/agentProfile.displayLabel.test.js
diff --git a/backend/__tests__/service/install.preserves-displayname.test.js b/backend/__tests__/service/install.preserves-displayname.test.js
index 421926624d..cc1d1380f3 100644
--- a/backend/__tests__/service/install.preserves-displayname.test.js
+++ b/backend/__tests__/service/install.preserves-displayname.test.js
@@ -139,6 +139,50 @@ describe('Install endpoint preserves curated displayName (cycle-of-Aria regressi
expect(profile.name).not.toBe('Cuz 🦞');
});
+ it('does not promote a legacy runtime-shaped User displayName into either higher-precedence label', async () => {
+ await User.create({
+ username: 'openclaw-nova',
+ email: 'openclaw-nova@bot.local',
+ password: 'bot-no-login',
+ isBot: true,
+ botMetadata: {
+ agentName: 'openclaw',
+ instanceId: 'nova',
+ // A historical writer stored the runtime rather than Nova’s label.
+ displayName: 'openclaw (nova)',
+ },
+ });
+
+ const res = await request(app)
+ .post('/api/registry/install')
+ .set('Authorization', `Bearer ${installerToken}`)
+ .send({
+ agentName: 'openclaw',
+ instanceId: 'nova',
+ podId: pod._id.toString(),
+ version: '1.0.0',
+ scopes: ['context:read', 'summaries:read', 'messages:write'],
+ });
+
+ expect(res.status).toBe(200);
+
+ const installation = await AgentInstallation.findOne({
+ podId: pod._id,
+ agentName: 'openclaw',
+ instanceId: 'nova',
+ }).lean();
+ expect(installation.displayName).toBe('nova');
+ expect(installation.displayName).not.toBe('openclaw (nova)');
+
+ const profile = await AgentProfile.findOne({
+ podId: pod._id,
+ agentName: 'openclaw',
+ instanceId: 'nova',
+ }).lean();
+ expect(profile.name).toBe('nova');
+ expect(profile.name).not.toBe('openclaw (nova)');
+ });
+
it('respects explicit displayName in request body over both registry default AND existing User row', async () => {
const res = await request(app)
.post('/api/registry/install')
diff --git a/backend/__tests__/unit/controllers/userController.test.js b/backend/__tests__/unit/controllers/userController.test.js
index e4f71f0c19..b36a01fd69 100644
--- a/backend/__tests__/unit/controllers/userController.test.js
+++ b/backend/__tests__/unit/controllers/userController.test.js
@@ -1,9 +1,11 @@
jest.mock('../../../services/agentIdentityService', () => ({
syncUserToPostgreSQL: jest.fn().mockResolvedValue(undefined),
+ resolveAgentDisplayLabel: jest.fn((user, fallback) => user.botMetadata?.displayName || fallback),
}));
const User = require('../../../models/User');
const userController = require('../../../controllers/userController');
+const { resolveAgentDisplayLabel } = require('../../../services/agentIdentityService');
const mockUserDoc = (fields) => ({
...fields,
@@ -91,6 +93,26 @@ describe('User Controller', () => {
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ _id: 'u1', username: 'test' }));
});
+ it('returns the curated display label for an agent seat', async () => {
+ const mockUser = mockUserDoc({
+ _id: 'u1',
+ username: 'vale',
+ isBot: true,
+ botMetadata: { displayName: 'Vale', agentName: 'openclaw', instanceId: 'vale' },
+ });
+ User.findById = jest.fn().mockReturnValue({ select: jest.fn().mockResolvedValueOnce(mockUser) });
+ const req = { params: { id: 'u1' }, user: { id: 'viewer' } };
+ const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };
+
+ await userController.getUserById(req, res);
+
+ expect(resolveAgentDisplayLabel).toHaveBeenCalledWith(mockUser, 'vale');
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
+ username: 'vale',
+ displayName: 'Vale',
+ }));
+ });
+
it('returns 404 if the user is not found', async () => {
User.findById = jest.fn().mockReturnValue({ select: jest.fn().mockResolvedValueOnce(null) });
const req = { params: { id: 'missing' }, user: { id: 'viewer' } };
diff --git a/backend/__tests__/unit/routes/agentProfile.avatar.test.js b/backend/__tests__/unit/routes/agentProfile.avatar.test.js
index 5cd7c8a27b..65cbb0366d 100644
--- a/backend/__tests__/unit/routes/agentProfile.avatar.test.js
+++ b/backend/__tests__/unit/routes/agentProfile.avatar.test.js
@@ -14,6 +14,7 @@ jest.mock('../../../models/User', () => ({
}));
jest.mock('../../../models/Pod', () => ({}));
jest.mock('../../../models/AgentMemory', () => ({}));
+jest.mock('../../../models/AgentProfile', () => ({}));
jest.mock('../../../models/AgentRun', () => ({}));
jest.mock('../../../models/PodAsset', () => ({}));
jest.mock('../../../models/pg/Message', () => ({}));
diff --git a/backend/__tests__/unit/routes/agentProfile.displayLabel.test.js b/backend/__tests__/unit/routes/agentProfile.displayLabel.test.js
new file mode 100644
index 0000000000..1eaede9f0f
--- /dev/null
+++ b/backend/__tests__/unit/routes/agentProfile.displayLabel.test.js
@@ -0,0 +1,75 @@
+jest.mock('../../../models/User', () => ({ findOne: jest.fn() }));
+jest.mock('../../../models/Pod', () => ({ find: jest.fn() }));
+jest.mock('../../../models/AgentMemory', () => ({ findOne: jest.fn() }));
+jest.mock('../../../models/AgentProfile', () => ({ find: jest.fn() }));
+jest.mock('../../../models/AgentRun', () => ({ find: jest.fn() }));
+jest.mock('../../../models/PodAsset', () => ({ find: jest.fn() }));
+jest.mock('../../../models/pg/Message', () => ({ findMostRecentPodActivity: jest.fn() }));
+jest.mock('../../../models/AgentRegistry', () => ({ AgentInstallation: { find: jest.fn() } }));
+jest.mock('../../../services/agentIdentityService', () => ({
+ resolveAgentDisplayLabel: jest.fn(() => 'runtime-seat'),
+}));
+jest.mock('../../../middleware/auth', () => jest.fn((req, res, next) => next()));
+
+const User = require('../../../models/User');
+const Pod = require('../../../models/Pod');
+const AgentMemory = require('../../../models/AgentMemory');
+const AgentProfile = require('../../../models/AgentProfile');
+const AgentRun = require('../../../models/AgentRun');
+const PodAsset = require('../../../models/PodAsset');
+const PGMessage = require('../../../models/pg/Message');
+const { AgentInstallation } = require('../../../models/AgentRegistry');
+const { resolveAgentDisplayLabel } = require('../../../services/agentIdentityService');
+const router = require('../../../routes/agentProfile');
+
+const getHandler = (method, path) => {
+ const layer = router.stack.find((entry) => (
+ entry.route && entry.route.path === path && entry.route.methods[method]
+ ));
+ if (!layer) throw new Error(`${method.toUpperCase()} ${path} handler not found`);
+ return layer.route.stack[layer.route.stack.length - 1].handle;
+};
+
+const response = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() });
+const leanResult = (value) => ({ lean: jest.fn().mockResolvedValue(value) });
+const selectLeanResult = (value) => ({ select: jest.fn().mockReturnValue(leanResult(value)) });
+
+const getProfile = getHandler('get', '/:agentName/:instanceId?');
+
+describe('public agent profile display label', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ User.findOne.mockReturnValue(selectLeanResult({
+ username: 'vale',
+ profilePicture: 'default',
+ isBot: true,
+ // `agentName` is the legacy seat identifier. It must not become the
+ // public runtime badge when the dedicated `runtimeId` is absent.
+ botMetadata: { agentName: 'vale', instanceId: 'vale' },
+ agentConfig: {},
+ createdAt: new Date(),
+ }));
+ PodAsset.find.mockReturnValue(selectLeanResult([]));
+ AgentInstallation.find.mockReturnValue({
+ sort: jest.fn().mockReturnValue(selectLeanResult([{ podId: 'pod-1', displayName: 'Vale install' }])),
+ });
+ AgentProfile.find.mockReturnValue(selectLeanResult([{ podId: 'pod-1', name: 'Vale' }]));
+ Pod.find.mockReturnValue(selectLeanResult([]));
+ AgentMemory.findOne.mockReturnValue(selectLeanResult(null));
+ AgentRun.find.mockReturnValue({
+ sort: jest.fn().mockReturnValue({ limit: jest.fn().mockReturnValue(selectLeanResult([])) }),
+ });
+ PGMessage.findMostRecentPodActivity.mockResolvedValue([]);
+ });
+
+ it('prefers the active pod profile label over the raw runtime seat username', async () => {
+ const res = response();
+
+ await getProfile({ params: { agentName: 'openclaw', instanceId: 'vale' } }, res);
+
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
+ agent: expect.objectContaining({ displayName: 'Vale', runtime: null }),
+ }));
+ expect(resolveAgentDisplayLabel).not.toHaveBeenCalled();
+ });
+});
diff --git a/backend/controllers/userController.ts b/backend/controllers/userController.ts
index 8305e8f686..3e0899e30e 100644
--- a/backend/controllers/userController.ts
+++ b/backend/controllers/userController.ts
@@ -60,8 +60,18 @@ const toSocialProfile = (userDoc: any, viewerId: any = null) => {
for (const field of PRIVATE_USER_FIELDS) delete plain[field];
}
+ // Agent User rows keep their durable seat username for authentication and
+ // routing (for example, `vale`). The social surface must use the same
+ // curated identity label as the rest of the product (for example, `Vale`).
+ // Humans intentionally retain their username: their display label is not a
+ // separate identity field.
+ const displayName = userDoc.isBot
+ ? AgentIdentityService.resolveAgentDisplayLabel(userDoc, userDoc.username)
+ : userDoc.username;
+
return {
...plain,
+ displayName,
followersCount: followers.length,
followingCount: following.length,
followedThreadsCount: Array.isArray(userDoc.followedThreads) ? userDoc.followedThreads.length : 0,
diff --git a/backend/routes/agentProfile.ts b/backend/routes/agentProfile.ts
index 92de49a917..66eb91f9a1 100644
--- a/backend/routes/agentProfile.ts
+++ b/backend/routes/agentProfile.ts
@@ -31,6 +31,8 @@ const Pod = require('../models/Pod');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AgentInstallation } = require('../models/AgentRegistry');
// eslint-disable-next-line @typescript-eslint/no-require-imports
+const AgentProfile = require('../models/AgentProfile');
+// eslint-disable-next-line @typescript-eslint/no-require-imports
const AgentMemory = require('../models/AgentMemory');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const AgentRun = require('../models/AgentRun');
@@ -129,11 +131,40 @@ router.get('/:agentName/:instanceId?', async (req: Req, res: Res) => {
instanceId,
status: 'active',
})
- .select('podId')
+ // A public profile is identity-wide, while these two label fields are
+ // pod-scoped. Sort so the first active attachment is deterministic; its
+ // profile label has the same precedence as the Your Team payload.
+ .sort({ createdAt: 1, _id: 1 })
+ .select('podId displayName')
.lean();
const ownerPodIds = (installs as Array<{ podId?: { toString(): string } }>)
.map((i) => (i?.podId ? String(i.podId) : ''))
.filter(Boolean);
+ // AgentProfile.name and AgentInstallation.displayName are the curated
+ // labels written at install time. The User row's username is a stable
+ // runtime seat identifier, not necessarily a human-facing name. Pair each
+ // profile with its active installation so a stale, detached profile cannot
+ // affect this public identity card.
+ const scopedProfiles = ownerPodIds.length
+ ? await AgentProfile.find({
+ podId: { $in: ownerPodIds },
+ agentName,
+ instanceId,
+ status: 'active',
+ }).select('podId name').lean()
+ : [];
+ const profileNameByPodId = new Map(
+ (scopedProfiles as Array<{ podId?: unknown; name?: string }>).map((profile) => [
+ String(profile.podId),
+ typeof profile.name === 'string' ? profile.name.trim() : '',
+ ]),
+ );
+ const scopedDisplayName = (installs as Array<{ podId?: unknown; displayName?: string }>)
+ .map((installation) => (
+ profileNameByPodId.get(String(installation.podId))
+ || (typeof installation.displayName === 'string' ? installation.displayName.trim() : '')
+ ))
+ .find(Boolean);
const publicPodDocs = ownerPodIds.length
? await Pod.find({ _id: { $in: ownerPodIds }, publicRead: true }).select('name').lean()
: [];
@@ -198,9 +229,11 @@ router.get('/:agentName/:instanceId?', async (req: Req, res: Res) => {
agent: {
agentName,
instanceId,
- displayName: resolveAgentDisplayLabel(user, user.username),
+ displayName: scopedDisplayName || resolveAgentDisplayLabel(user, user.username),
profilePicture: user.profilePicture || 'default',
- runtime: bm.runtimeId ? String(bm.runtimeId) : (bm.agentName ? String(bm.agentName) : null),
+ // agentName is a legacy identity field, not a runtime descriptor. Falling
+ // back to it rendered the raw seat name as a misleading runtime badge.
+ runtime: bm.runtimeId ? String(bm.runtimeId) : null,
officialAgent: !!bm.officialAgent,
description: bm.description ? String(bm.description) : undefined,
capabilities: Array.isArray(bm.capabilities) ? (bm.capabilities as string[]) : [],
diff --git a/backend/routes/registry/install.ts b/backend/routes/registry/install.ts
index 6c4adbb0da..3b7b4e5568 100644
--- a/backend/routes/registry/install.ts
+++ b/backend/routes/registry/install.ts
@@ -396,7 +396,10 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any
// AND AgentProfile.name — and the V2 member list reads AgentProfile FIRST,
// so users see "Cuz" on every member row even though the underlying
// identity has the right name. Order: explicit caller intent > existing
- // identity > registry default.
+ // identity > registry default. Resolve the existing identity through the
+ // same leak guard every display surface uses: historical rows may contain
+ // a runtime-shaped displayName such as "openclaw (nova)", which must not
+ // become a higher-precedence installation or profile label.
let effectiveDisplayName: string = displayName || '';
if (!effectiveDisplayName) {
try {
@@ -404,9 +407,9 @@ installRouter.post('/install', installRateLimit, auth, async (req: any, res: any
'botMetadata.agentName': agent.agentName,
'botMetadata.instanceId': normalizedInstanceId,
isBot: true,
- }).select('botMetadata.displayName').lean();
+ }).select('username botMetadata.displayName botMetadata.agentName botMetadata.instanceId').lean();
if (existingAgentUser?.botMetadata?.displayName) {
- effectiveDisplayName = String(existingAgentUser.botMetadata.displayName);
+ effectiveDisplayName = AgentIdentityService.resolveAgentDisplayLabel(existingAgentUser, '');
}
} catch (lookupErr) {
// Non-fatal — fall through to registry default below.
diff --git a/frontend/src/components/UserProfile.test.tsx b/frontend/src/components/UserProfile.test.tsx
index 9019238496..b4ed1dd921 100644
--- a/frontend/src/components/UserProfile.test.tsx
+++ b/frontend/src/components/UserProfile.test.tsx
@@ -14,6 +14,12 @@ jest.mock('axios', () => ({
}));
jest.mock('../context/AppContext', () => ({ useAppContext: jest.fn() }));
jest.mock('../context/AuthContext', () => ({ useAuth: jest.fn() }));
+jest.mock('../utils/avatarUtils', () => ({
+ avatarOptions: [],
+ getAvatarColor: jest.fn(() => '#000'),
+ getAvatarSrc: jest.fn(() => undefined),
+ presetFaceOptions: jest.fn(() => []),
+}));
jest.mock('./admin/AdminUsers', () => () => null);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -40,9 +46,9 @@ afterEach(() => {
localStorage.clear();
});
-async function renderProfile() {
+async function renderProfile(profile = { _id: 'u', username: 'user', email: 'e@example.com', createdAt: '2023-01-01', profilePicture: 'default' }) {
axios.get
- .mockResolvedValueOnce({ data: { _id: 'u', username: 'user', email: 'e@example.com', createdAt: '2023-01-01', profilePicture: 'default' } })
+ .mockResolvedValueOnce({ data: profile })
.mockResolvedValueOnce({ data: [] })
.mockResolvedValueOnce({ data: { hasToken: false } })
.mockResolvedValueOnce({ data: { recentPublicPosts: [], joinedPods: [] } });
@@ -62,6 +68,19 @@ test('displays user info after fetch', async () => {
expect(container.textContent).toContain('user');
});
+test('uses an agent display label instead of its raw seat username', async () => {
+ await renderProfile({
+ _id: 'u',
+ username: 'vale',
+ displayName: 'Vale',
+ email: 'vale@agents.commonly.local',
+ createdAt: '2023-01-01',
+ profilePicture: 'default',
+ });
+
+ expect(container.querySelector('h4')?.textContent).toBe('Vale');
+});
+
test('shows error when fetch fails', async () => {
axios.get.mockRejectedValue(new Error('fail'));
await TestUtils.act(async () => {
diff --git a/frontend/src/components/UserProfile.tsx b/frontend/src/components/UserProfile.tsx
index 64b9c0afd6..1f9f4e9637 100644
--- a/frontend/src/components/UserProfile.tsx
+++ b/frontend/src/components/UserProfile.tsx
@@ -46,6 +46,7 @@ import { useV2Embedded } from '../v2/hooks/useV2Embedded';
interface ProfileUser {
_id: string;
username: string;
+ displayName?: string;
email: string;
role?: string;
profilePicture?: string;
@@ -384,6 +385,7 @@ const UserProfile = () => {
);
const isOwnProfile = !profileId || (currentUser && currentUser._id === user._id);
const isSelectedAvatarColor = avatarOptions.some((avatar) => avatar.id === selectedAvatar);
+ const profileDisplayName = user.displayName || user.username;
return (
@@ -402,7 +404,7 @@ const UserProfile = () => {
}}
src={getAvatarSrc(user.profilePicture)}
>
- {user.username.charAt(0).toUpperCase()}
+ {profileDisplayName.charAt(0).toUpperCase()}
{isOwnProfile && (
{
and keep the secondary metadata only. */}
{!v2Embedded && (
- {user.username}
+ {profileDisplayName}
)}
{v2Embedded && (
- {user.username}
+ {profileDisplayName}
)}
@@ -827,7 +829,7 @@ const UserProfile = () => {
}}
src={avatarPreview || (isSelectedAvatarColor ? undefined : getAvatarSrc(selectedAvatar)) || undefined}
>
- {user.username.charAt(0).toUpperCase()}
+ {profileDisplayName.charAt(0).toUpperCase()}