Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions app/src/agentworld/pages/ProfilesSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock('../AgentWorldShell', () => ({
follows: { stats: vi.fn() },
registry: { export: vi.fn(), assignPrimary: vi.fn() },
graphql: { user: vi.fn() },
users: { get: vi.fn(), updateProfile: vi.fn() },
},
}));
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
Expand All @@ -31,6 +32,8 @@ const followStats = vi.mocked(apiClient.follows.stats);
const registryExport = vi.mocked(apiClient.registry.export);
const assignPrimary = vi.mocked(apiClient.registry.assignPrimary);
const graphqlUser = vi.mocked(apiClient.graphql.user);
const usersGet = vi.mocked(apiClient.users.get);
const updateProfile = vi.mocked(apiClient.users.updateProfile);

const SOLANA_ADDR = 'WaLLetSoLanaAddr0123456789';

Expand All @@ -51,6 +54,26 @@ beforeEach(() => {
graphqlUser.mockResolvedValue(null);
reverse.mockResolvedValue({ cryptoId: SOLANA_ADDR, identities: [] });
followStats.mockResolvedValue({ agentId: '', followerCount: 0, followingCount: 0 });
// Own-profile edit path (#4930): users.get seeds the writable fields, and
// updateProfile echoes back the saved User.
usersGet.mockResolvedValue({
cryptoId: SOLANA_ADDR,
actorType: 'agent',
displayName: 'Test Agent',
bio: '',
emailVerified: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
} as unknown as Awaited<ReturnType<typeof apiClient.users.get>>);
updateProfile.mockResolvedValue({
cryptoId: SOLANA_ADDR,
actorType: 'agent',
displayName: 'Test Agent',
bio: '',
emailVerified: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
} as unknown as Awaited<ReturnType<typeof apiClient.users.updateProfile>>);
});

// ── Loading ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -483,3 +506,120 @@ describe('graphql-enriched profile card', () => {
expect(screen.queryByText('Verified Accounts')).not.toBeInTheDocument();
});
});

// ── Own-profile editing (#4930) ──────────────────────────────────────────────

describe('own-profile editing', () => {
test('offers an Edit profile button on the own graphql profile', async () => {
graphqlUser.mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' }));
render(<ProfilesSection />);
expect(await screen.findByText('Agent Alice')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit profile/i })).toBeInTheDocument();
});

test('saves name + bio via users.updateProfile, then refetches so saved values show', async () => {
graphqlUser
.mockResolvedValueOnce(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' }))
.mockResolvedValue(makeProfile({ displayName: 'Agent Alice v2', bio: 'New bio' }));
usersGet.mockResolvedValue({
cryptoId: SOLANA_ADDR,
actorType: 'agent',
displayName: 'Agent Alice',
bio: 'Old bio',
emailVerified: false,
createdAt: '',
updatedAt: '',
} as unknown as Awaited<ReturnType<typeof apiClient.users.get>>);

render(<ProfilesSection />);
fireEvent.click(await screen.findByRole('button', { name: /edit profile/i }));

const nameInput = await screen.findByRole('textbox', { name: /display name/i });
// Prefilled from users.get (the authoritative writable record).
await waitFor(() => expect(nameInput).toHaveValue('Agent Alice'));
const bioInput = screen.getByRole('textbox', { name: /^bio$/i });

fireEvent.change(nameInput, { target: { value: 'Agent Alice v2' } });
fireEvent.change(bioInput, { target: { value: 'New bio' } });
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));

await waitFor(() =>
expect(updateProfile).toHaveBeenCalledWith(
SOLANA_ADDR,
expect.objectContaining({ displayName: 'Agent Alice v2', bio: 'New bio' })
)
);
// Refetch renders the saved values.
expect(await screen.findByText('Agent Alice v2')).toBeInTheDocument();
});
Comment on lines +520 to +554

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add coverage for clearing an existing avatar email.

This test only asserts name and bio, so omitting a blank avatarEmail still passes. Prefill an existing email, clear it, save, and require:

expect(updateProfile).toHaveBeenCalledWith(
  SOLANA_ADDR,
  expect.objectContaining({ avatarEmail: '' })
);

Based on the PR objectives, clearing the editable avatar email is a known unprotected contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/agentworld/pages/ProfilesSection.test.tsx` around lines 520 - 554,
Extend the test around ProfilesSection’s save flow to prefill an existing avatar
email, clear the editable avatar email before saving, and assert that
updateProfile is called with SOLANA_ADDR and avatarEmail: ''. Keep the existing
name and bio assertions intact while ensuring the cleared value is explicitly
covered.


test('a late prefill fetch does not clobber what the user already typed (#4930)', async () => {
graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' }));
// Defer users.get so it resolves AFTER the user has started editing.
let resolveGet!: (value: unknown) => void;
const deferred = new Promise<unknown>(res => {
resolveGet = res;
});
usersGet.mockReturnValue(deferred as unknown as ReturnType<typeof apiClient.users.get>);

render(<ProfilesSection />);
fireEvent.click(await screen.findByRole('button', { name: /edit profile/i }));

const nameInput = await screen.findByRole('textbox', { name: /display name/i });
// User types before the prefill lands.
fireEvent.change(nameInput, { target: { value: 'My New Name' } });

// Prefill resolves late with the authoritative (different) record.
resolveGet({
cryptoId: SOLANA_ADDR,
actorType: 'agent',
displayName: 'Agent Alice',
bio: 'Old bio',
avatarEmail: 'alice@example.com',
emailVerified: false,
createdAt: '',
updatedAt: '',
});

// The user's edit survives; the prefill must not overwrite the touched field.
await waitFor(() => expect(nameInput).toHaveValue('My New Name'));
});
Comment on lines +556 to +586

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wait for evidence that the delayed prefill was applied.

Line 585 can pass immediately while the deferred promise’s state update is still pending. First wait until an untouched field—such as avatar email—contains the deferred value, then assert that the touched name remains unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/agentworld/pages/ProfilesSection.test.tsx` around lines 556 - 586,
Update the test around ProfilesSection’s deferred usersGet prefill so it first
waits for an untouched field, such as the avatar email input, to contain the
resolved deferred value. Only after that prefill-application evidence is
observed should it assert that the edited display name still equals “My New
Name.”


test('keeps the form open and surfaces an error when save fails', async () => {
graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' }));
updateProfile.mockRejectedValueOnce(new Error('network down'));

render(<ProfilesSection />);
fireEvent.click(await screen.findByRole('button', { name: /edit profile/i }));
fireEvent.click(await screen.findByRole('button', { name: /^save$/i }));

expect(await screen.findByText(/could not save your profile/i)).toBeInTheDocument();
expect(screen.getByTestId('profile-edit-form')).toBeInTheDocument();
expect(updateProfile).toHaveBeenCalledTimes(1);
});

test('will not save an empty display name (guards against blanking it)', async () => {
graphqlUser.mockResolvedValue(makeProfile({ displayName: 'Agent Alice', bio: 'Old bio' }));
usersGet.mockResolvedValue({
cryptoId: SOLANA_ADDR,
actorType: 'agent',
displayName: 'Agent Alice',
bio: 'Old bio',
emailVerified: false,
createdAt: '',
updatedAt: '',
} as unknown as Awaited<ReturnType<typeof apiClient.users.get>>);

render(<ProfilesSection />);
fireEvent.click(await screen.findByRole('button', { name: /edit profile/i }));
const nameInput = await screen.findByRole('textbox', { name: /display name/i });
await waitFor(() => expect(nameInput).toHaveValue('Agent Alice'));

// Clear the name → Save is disabled and never blanks the profile.
fireEvent.change(nameInput, { target: { value: ' ' } });
const saveBtn = screen.getByRole('button', { name: /^save$/i });
expect(saveBtn).toBeDisabled();
fireEvent.click(saveBtn);
expect(updateProfile).not.toHaveBeenCalled();
});
});
199 changes: 198 additions & 1 deletion app/src/agentworld/pages/ProfilesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* "register a handle" prompt when the wallet owns none, and a wallet-locked
* notice when the wallet isn't set up.
*/
import { useCallback, useEffect, useState } from 'react';
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';

import PanelScaffold from '../../components/layout/PanelScaffold';
import Button from '../../components/ui/Button';
Expand All @@ -18,9 +19,12 @@ import {
type IdentityExport,
PaymentRequiredError,
} from '../../lib/agentworld/invokeApiClient';
import { useT } from '../../lib/i18n/I18nContext';
import { fetchWalletStatus } from '../../services/walletApi';
import { apiClient } from '../AgentWorldShell';

const log = debug('agentworld:profile');

/** A handle registered to the wallet (subset of the directory.reverse identity). */
interface OwnedIdentity {
username?: string;
Expand Down Expand Up @@ -168,7 +172,175 @@ function useMyIdentity(reloadKey: number): ProfileState {

// ── Sub-components ────────────────────────────────────────────────────────────

/**
* Own-profile edit form (name / bio / avatar), wired to the already-plumbed
* `users.updateProfile` write path (#4930). Prefills the writable fields from
* the authoritative `users.get` record — including `avatarEmail`, which the
* display-side `GqlProfile` does not carry (the shown avatar URL is derived
* server-side from the Gravatar email). On success the parent refetches so the
* saved values render.
*/
function ProfileEditForm({
cryptoId,
initialDisplayName,
initialBio,
onCancel,
onSaved,
}: {
cryptoId: string;
initialDisplayName: string;
initialBio: string;
onCancel: () => void;
onSaved: () => void;
}) {
const { t } = useT();
const [displayName, setDisplayName] = useState(initialDisplayName);
const [bio, setBio] = useState(initialBio);
const [avatarEmail, setAvatarEmail] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Which fields the user has edited. The async prefill below must never
// overwrite a field the user already started typing in (its fetch can resolve
// after the user opens the form and begins editing — silently discarding
// their input otherwise, #4930 review).
const touched = useRef({ displayName: false, bio: false, avatarEmail: false });

// Seed the writable fields (notably avatarEmail, absent from GqlProfile) from
// the authoritative user record. Non-fatal: on failure we keep the values
// already seeded from the displayed profile. Untouched fields only.
useEffect(() => {
let cancelled = false;
void apiClient.users
.get(cryptoId)
.then(user => {
if (cancelled) return;
if (!touched.current.displayName && typeof user?.displayName === 'string')
setDisplayName(user.displayName);
if (!touched.current.bio && typeof user?.bio === 'string') setBio(user.bio);
if (!touched.current.avatarEmail && typeof user?.avatarEmail === 'string')
setAvatarEmail(user.avatarEmail);
})
.catch((err: unknown) => {
log('prefill from users.get failed: %s', String(err));
});
return () => {
cancelled = true;
};
}, [cryptoId]);

const handleSave = useCallback(async () => {
// A display name is required: `displayName`/`bio` are always sent trimmed,
// so an empty name would blank the existing one. Guard here (and disable
// Save below) rather than silently clearing it. `bio` may be empty; only
// `avatarEmail` is omitted-when-empty (so an existing avatar isn't cleared).
if (!displayName.trim()) {
setError(t('agentWorld.profile.nameRequired', 'Display name cannot be empty.'));
return;
}
setSaving(true);
setError(null);
log('saving profile update');
try {
await apiClient.users.updateProfile(cryptoId, {
displayName: displayName.trim(),
bio: bio.trim(),
// Only send avatarEmail when the user provided one, so an untouched
// (empty) field never blanks an existing avatar. It is a Gravatar
// email, not an image URL.
...(avatarEmail.trim() ? { avatarEmail: avatarEmail.trim() } : {}),
});
log('profile update saved');
onSaved();
} catch (err) {
log('profile update failed: %s', String(err));
setError(t('agentWorld.profile.saveError', 'Could not save your profile. Try again.'));
} finally {
setSaving(false);
}
}, [avatarEmail, bio, cryptoId, displayName, onSaved, t]);

const inputClass =
'w-full rounded-md border border-line bg-surface px-2.5 py-1.5 text-sm text-content ' +
'placeholder:text-content-faint focus:border-primary-500 focus:outline-none';

return (
<form
data-testid="profile-edit-form"
className="space-y-3"
onSubmit={e => {
e.preventDefault();
void handleSave();
}}>
<label className="block space-y-1">
<span className="text-xs font-medium text-content">
{t('agentWorld.profile.displayName', 'Display name')}
</span>
<input
type="text"
value={displayName}
disabled={saving}
onChange={e => {
touched.current.displayName = true;
setDisplayName(e.target.value);
}}
className={inputClass}
/>
</label>

<label className="block space-y-1">
<span className="text-xs font-medium text-content">
{t('agentWorld.profile.bio', 'Bio')}
</span>
<textarea
value={bio}
rows={3}
disabled={saving}
onChange={e => {
touched.current.bio = true;
setBio(e.target.value);
}}
className={`${inputClass} resize-y`}
/>
</label>

<label className="block space-y-1">
<span className="text-xs font-medium text-content">
{t('agentWorld.profile.avatarEmail', 'Avatar email')}
</span>
<input
type="email"
value={avatarEmail}
disabled={saving}
onChange={e => {
touched.current.avatarEmail = true;
setAvatarEmail(e.target.value);
}}
className={inputClass}
/>
<span className="text-[11px] text-content-muted">
{t('agentWorld.profile.avatarEmailHint', 'Used to fetch your avatar from Gravatar.')}
</span>
</label>

{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}

<div className="flex items-center gap-2">
<Button type="submit" variant="primary" size="sm" disabled={saving || !displayName.trim()}>
{saving
? t('agentWorld.profile.saving', 'Saving…')
: t('agentWorld.profile.save', 'Save')}
</Button>
<Button type="button" variant="secondary" size="sm" disabled={saving} onClick={onCancel}>
{t('agentWorld.profile.cancel', 'Cancel')}
</Button>
</div>
</form>
);
}

function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?: () => void }) {
const { t } = useT();
const [editing, setEditing] = useState(false);
const [followStats, setFollowStats] = useState<FollowStats | null>(null);
const [exportData, setExportData] = useState<IdentityExport | null>(null);
const [exportLoading, setExportLoading] = useState(false);
Expand Down Expand Up @@ -312,6 +484,31 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?
</div>
</div>

{/* Own-profile editing (#4930). Only the graphql source carries an
editable profile + cryptoId; the bare directory fallback has neither. */}
{isGraphql && cryptoId && (
Comment thread
M3gA-Mind marked this conversation as resolved.
<div className="mt-4 border-t border-line pt-4">
{editing ? (
<ProfileEditForm
cryptoId={cryptoId}
initialDisplayName={displayName}
initialBio={bio}
onCancel={() => setEditing(false)}
onSaved={() => {
setEditing(false);
// Refetch so the saved name/bio/avatar render (reuses the
// active-handle-switch reload path).
onSwitched?.();
}}
/>
) : (
<Button variant="secondary" size="sm" onClick={() => setEditing(true)}>
{t('agentWorld.profile.edit', 'Edit profile')}
</Button>
)}
</div>
)}

{skills.length > 0 && (
<div className="mt-4 border-t border-line pt-4">
<h4 className="mb-2 text-xs font-medium text-content">Skills</h4>
Expand Down
Loading
Loading