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
15 changes: 15 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2867,6 +2867,21 @@
}
},
"agentSettings": {
"name": "Name",
"rename": "Rename",
"keyActions": "Key actions",
"keyUsed": "used {{when}}",
"manageListing": "Manage listing",
"connectors": "Connectors",
"none": "None",
"grantConnector": "Grant a connector",
"memoryNotes_one": "{{count}} note",
"memoryNotes_other": "{{count}} notes",
"memorySaved": "saved {{when}}",
"skillCount_one": "{{count}} skill",
"skillCount_other": "{{count}} skills",
"podCount_one": "{{count}} pod",
"podCount_other": "{{count}} pods",
"identityTitle": "Identity",
"identityAccess": "Identity & access",
"keyActive": "active",
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2870,6 +2870,21 @@
}
},
"agentSettings": {
"name": "名称",
"rename": "重命名",
"keyActions": "密钥操作",
"keyUsed": "{{when}}使用过",
"manageListing": "管理市场条目",
"connectors": "连接器",
"none": "暂无",
"grantConnector": "授权连接器",
"memoryNotes_one": "{{count}}条笔记",
"memoryNotes_other": "{{count}}条笔记",
"memorySaved": "{{when}}更新",
"skillCount_one": "{{count}}个技能",
"skillCount_other": "{{count}}个技能",
"podCount_one": "{{count}}个 Pod",
"podCount_other": "{{count}}个 Pod",
"identityTitle": "身份",
"identityAccess": "身份与访问",
"keyActive": "已启用",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/v2/V2App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ const V2App: React.FC = () => {
/>
<Route
path="agents/:agentName/:instanceId"
element={feature('Agent settings', <V2AgentSettingsPage />, false, false)}
element={feature('Agent settings', <V2AgentSettingsPage />, false, false, 'v2-feature--agent-settings')}
/>
<Route
path="agents/:agentName/:instanceId/memory"
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/v2/__tests__/V2AgentControlSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ it('loads connection state from an installer-owned pod', async () => {
.toHaveAttribute('href', '/v2/agents/byo?pod=owned-pod&name=local&instanceId=default&reconnect=1&mode=machine');
expect(api.get).toHaveBeenCalledWith('/api/registry/agents/local/manage-settings',
{ params: { instanceId: 'default' } });
expect(onConnectionState).toHaveBeenCalledWith('revoked');
expect(onConnectionState).toHaveBeenCalledWith('revoked', { state: 'revoked', lastSeenAt: null });
});

it('refreshes the header after a key change and drops the old connected state', async () => {
Expand Down Expand Up @@ -128,3 +128,36 @@ it('uses unavailable only when the roster has no connection signal', async () =>
render(<MemoryRouter><AgentControlLoader agentName="local" instanceId="default" /></MemoryRouter>);
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connection status unavailable'));
});

describe('hideWhenConnected (Settings reports a healthy connection in its header)', () => {
const showHidden = (state: 'revoked' | 'connected', runtimeType = 'webhook') => render(
<MemoryRouter><V2AgentControlSection agentName="local" instanceId="default" podId="pod-1" runtimeType={runtimeType}
hideWhenConnected connection={{ state, lastSeenAt: null }} /></MemoryRouter>,
);

it('renders nothing for a connected webhook agent', () => {
showHidden('connected');
expect(screen.queryByTestId('agent-control-section')).toBeNull();
});

it('keeps the row for a revoked agent, with its Reconnect action', () => {
showHidden('revoked');
expect(screen.getByTestId('agent-control-section')).toHaveTextContent('Disconnected: key revoked');
expect(screen.getByRole('link', { name: 'Reconnect' })).toBeInTheDocument();
});

it('keeps a connected wrapper row, whose machine-key regeneration lives only there', () => {
showHidden('connected', 'wrapper');
expect(screen.getByRole('link', { name: 'Regenerate key' })).toBeInTheDocument();
});

it('shows nothing while the first read is in flight, and the unavailable alert after a failure', async () => {
api.get.mockImplementation(() => new Promise(() => {}));
const view = render(<MemoryRouter><AgentControlLoader agentName="local" instanceId="default" hideWhenConnected /></MemoryRouter>);
expect(screen.queryByTestId('agent-control-section')).toBeNull();
view.unmount();
api.get.mockImplementation(() => Promise.reject(new Error('offline')));
render(<MemoryRouter><AgentControlLoader agentName="local" instanceId="default" hideWhenConnected /></MemoryRouter>);
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Connection status unavailable'));
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// @ts-nocheck
// The registry-entry owner's controls in the agent identity section (TASK-419
// The registry-entry owner's controls in the agent settings Listing row (TASK-419
// part 2, UI). The GET names the publisher; only that user sees the rename and
// retire controls. The server re-checks ownership on every PATCH.
import React from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import axios from '../../utils/axiosConfig';
import V2AgentIdentitySection from '../agents/V2AgentIdentitySection';
import V2AgentListingRow from '../agents/V2AgentListingRow';
import { AuthContext } from '../../context/AuthContext';
import i18n, { i18nReady } from '../../i18n';

Expand All @@ -15,13 +15,6 @@ jest.mock('../../utils/axiosConfig', () => ({
default: { get: jest.fn(), patch: jest.fn() },
}));

const installation = {
name: 'scout',
instanceId: 'scout-1',
displayName: 'Scout',
profile: null,
};

const entry = (publisherId, status = 'active') => ({
data: { status, registry: 'public', verified: true, displayName: 'Scout Agent', publisher: { userId: publisherId } },
});
Expand All @@ -30,15 +23,17 @@ const renderSection = (viewerId) => {
render(
<AuthContext.Provider value={{ currentUser: { _id: viewerId, username: 'maya' }, user: { _id: viewerId } }}>
<MemoryRouter>
<V2AgentIdentitySection installation={installation} update={jest.fn()} />
<div className="list"><V2AgentListingRow agentName="scout" instanceId="scout-1" /></div>
</MemoryRouter>
</AuthContext.Provider>,
);
};

const retireButton = () => screen.queryByRole('button', { name: 'Retire listing' });
// The owner controls sit behind the row's Manage listing toggle.
const openOwnerControls = async () => fireEvent.click(await screen.findByRole('button', { name: 'Manage listing' }));

describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)', () => {
describe('V2AgentListingRow — registry entry owner controls (TASK-419)', () => {
beforeAll(async () => {
await i18nReady;
await i18n.changeLanguage('en');
Expand All @@ -58,17 +53,27 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)',
// waiting for it makes the absence check run after the owner data lands.
await screen.findByText('Published');
expect(retireButton()).toBeNull();
expect(screen.queryByRole('button', { name: 'Manage listing' })).toBeNull();
// The public profile link is for every viewer.
expect(screen.getByRole('link', { name: 'View public profile' })).toHaveAttribute('href', '/v2/agent/scout/scout-1');
cleanup();

axios.get.mockResolvedValue(entry('owner-1'));
renderSection('owner-1');
const toggle = await screen.findByRole('button', { name: 'Manage listing' });
// Closed by default: the owner controls appear only once the toggle opens them.
expect(toggle).toHaveAttribute('aria-expanded', 'false');
expect(retireButton()).toBeNull();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByRole('button', { name: 'Retire listing' })).toBeInTheDocument();
});

test('retire PATCHes the owned-entry route and flips to restore', async () => {
axios.get.mockResolvedValue(entry('owner-1'));
axios.patch.mockResolvedValue({ data: { agentName: 'scout', displayName: 'Scout Agent', status: 'unpublished' } });
renderSection('owner-1');
await openOwnerControls();
fireEvent.click(await screen.findByRole('button', { name: 'Retire listing' }));

await waitFor(() => expect(axios.patch).toHaveBeenCalledTimes(1));
Expand All @@ -83,6 +88,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)',
axios.patch.mockResolvedValue({ data: { agentName: 'scout', displayName: 'Scout Agent', status: 'unpublished' } });
renderSection('owner-1');
await screen.findByText('Published');
await openOwnerControls();
fireEvent.click(await screen.findByRole('button', { name: 'Retire listing' }));

// Without the fix the status line keeps "Published" after the entry is retired.
Expand All @@ -95,6 +101,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)',
axios.patch.mockResolvedValue({ data: { agentName: 'scout', displayName: 'Scout Agent', status: 'active' } });
renderSection('owner-1');
await screen.findByText('Not published');
await openOwnerControls();
fireEvent.click(await screen.findByRole('button', { name: 'Restore listing' }));

expect(await screen.findByText('Published')).toBeInTheDocument();
Expand All @@ -104,6 +111,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)',
axios.get.mockResolvedValue(entry('owner-1'));
axios.patch.mockResolvedValue({ data: {} });
renderSection('owner-1');
await openOwnerControls();
const input = await screen.findByDisplayValue('Scout Agent');
fireEvent.change(input, { target: { value: ' Scout Pro ' } });
fireEvent.click(screen.getByRole('button', { name: 'Save listing name' }));
Expand All @@ -118,6 +126,7 @@ describe('V2AgentIdentitySection — registry entry owner controls (TASK-419)',
axios.get.mockResolvedValue(entry('owner-1'));
axios.patch.mockRejectedValue(new Error('500'));
renderSection('owner-1');
await openOwnerControls();
fireEvent.click(await screen.findByRole('button', { name: 'Retire listing' }));

expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't update this listing. Try again.");
Expand Down
Loading
Loading