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
17 changes: 13 additions & 4 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2775,7 +2775,8 @@
"targetUnavailable": "That computer is no longer available. Choose another computer.",
"placementChanged": "This agent’s computer changed. Review its current placement before moving it.",
"placementError": "Could not load this agent’s computer placement.",
"moveError": "Could not move this agent. Try again."
"moveError": "Could not move this agent. Try again.",
"machine": "Machine"
},
"grants": {
"title": "Connector grants",
Expand All @@ -2791,7 +2792,10 @@
"confirmRevoke": "Yes, revoke it",
"cancel": "Cancel",
"loadError": "Could not load connector grants.",
"revokeError": "Could not revoke the grant."
"revokeError": "Could not revoke the grant.",
"activeCount": "{{count}} active",
"endedCount": "{{count}} ended",
"retry": "Try again"
},
"controls": {
"title": "How it runs",
Expand Down Expand Up @@ -2838,7 +2842,8 @@
"remove": "Remove",
"set": "set",
"loadError": "Could not load key status.",
"saveError": "Could not update the key. Enter it again to retry."
"saveError": "Could not update the key. Enter it again to retry.",
"neverShown": "never shown again"
}
},
"agentSettings": {
Expand Down Expand Up @@ -2951,7 +2956,11 @@
"messageWakeShort_human": "People",
"messageWakeShort_all": "All",
"turnsUsed": "{{used}} / {{cap}} turns a day",
"turnsUsedNoCap": "{{used}} turns a day · cap unavailable"
"turnsUsedNoCap": "{{used}} turns a day · cap unavailable",
"specialtyCount_one": "{{count}} specialty",
"specialtyCount_other": "{{count}} specialties",
"boundaryCount_one": "{{count}} boundary",
"boundaryCount_other": "{{count}} boundaries"
},
"agentPolicy": {
"title": "Policy",
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2778,7 +2778,8 @@
"targetUnavailable": "这台电脑已不可用,请选择另一台电脑。",
"placementChanged": "此智能体的电脑分配已变更。请先查看当前分配,再重新迁移。",
"placementError": "无法加载此智能体的电脑分配信息。",
"moveError": "无法迁移此智能体,请重试。"
"moveError": "无法迁移此智能体,请重试。",
"machine": "电脑"
},
"grants": {
"title": "连接器授权",
Expand All @@ -2794,7 +2795,10 @@
"confirmRevoke": "确认撤销",
"cancel": "取消",
"loadError": "无法加载连接器授权。",
"revokeError": "无法撤销授权。"
"revokeError": "无法撤销授权。",
"activeCount": "{{count}}项生效中",
"endedCount": "{{count}}项已结束",
"retry": "重试"
},
"controls": {
"title": "运行方式",
Expand Down Expand Up @@ -2841,7 +2845,8 @@
"remove": "移除",
"set": "已设置",
"loadError": "无法加载密钥状态。",
"saveError": "无法更新密钥。请重新输入后重试。"
"saveError": "无法更新密钥。请重新输入后重试。",
"neverShown": "保存后不再显示"
}
},
"agentSettings": {
Expand Down Expand Up @@ -2954,7 +2959,9 @@
"messageWakeShort_human": "真人",
"messageWakeShort_all": "全部",
"turnsUsed": "{{used}} / {{cap}}次/天",
"turnsUsedNoCap": "{{used}}次/天 · 上限不可用"
"turnsUsedNoCap": "{{used}}次/天 · 上限不可用",
"specialtyCount_other": "{{count}}项专长",
"boundaryCount_other": "{{count}}条行为边界"
},
"agentPolicy": {
"title": "策略",
Expand Down
44 changes: 41 additions & 3 deletions frontend/src/v2/__tests__/V2AgentPolicy.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @ts-nocheck
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import V2AgentPolicy from '../agents/V2AgentPolicy';

jest.mock('axios', () => {
Expand Down Expand Up @@ -58,7 +58,7 @@ it('hides stored approval and context settings that no runtime enforces', async
it('writes only the chosen trust mode and edits memory with its revision', async () => {
renderPolicy();
await screen.findByDisplayValue('Original');
fireEvent.change(screen.getByLabelText('Repository access'), { target: { value: 'workspace' } });
fireEvent.click(screen.getByRole('radio', { name: 'Allow repository edits' }));
fireEvent.click(screen.getByRole('button', { name: 'Save trust mode' }));
await waitFor(() => expect(update).toHaveBeenCalledWith({
config: { environment: { sandbox: { mode: 'workspace' } } },
Expand All @@ -81,7 +81,8 @@ it('shows server quota for hosted agents without editable trust controls', async
it('offers trust mode for the canonical BYO runtime shape', async () => {
renderPolicy({ ...installation, runtime: { runtimeType: 'codex', host: 'byo' },
config: { environment: { sandbox: { mode: 'read-only' } } } });
expect(await screen.findByLabelText('Repository access')).toHaveValue('read-only');
expect(await screen.findByRole('radiogroup', { name: 'Repository access' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Read only' })).toBeChecked();
});

it('shows a retry in Memory when loading fails, then opens the editor', async () => {
Expand All @@ -97,3 +98,40 @@ it('shows a retry in Memory when loading fails, then opens the editor', async ()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByDisplayValue('Original')).toBeInTheDocument();
});

it('shows Saved in the trust row for a moment, then leaves the button alone', async () => {
renderPolicy();
await screen.findByDisplayValue('Original');
jest.useFakeTimers();
try {
fireEvent.click(screen.getByRole('radio', { name: 'Allow repository edits' }));
fireEvent.click(screen.getByRole('button', { name: 'Save trust mode' }));
expect(await screen.findByRole('status')).toHaveTextContent('Saved');
await act(async () => { jest.advanceTimersByTime(3000); });
expect(screen.queryByRole('status')).toBeNull();
expect(screen.getByRole('button', { name: 'Save trust mode' })).toBeInTheDocument();
} finally { jest.useRealTimers(); }
});

it('a failed trust save stays in the action slot', async () => {
update.mockRejectedValueOnce(new Error('Could not save changes.'));
renderPolicy();
await screen.findByDisplayValue('Original');
fireEvent.click(screen.getByRole('button', { name: 'Save trust mode' }));
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Could not save changes.');
expect(alert.closest('.ac')).toContainElement(screen.getByRole('button', { name: 'Save trust mode' }));
});

it('the memory editor keeps its Saved until the next action', async () => {
renderPolicy();
await screen.findByDisplayValue('Original');
jest.useFakeTimers();
try {
fireEvent.change(screen.getByLabelText('Long-term'), { target: { value: 'Changed' } });
fireEvent.click(screen.getByRole('button', { name: 'Save memory' }));
expect(await screen.findByRole('status')).toHaveTextContent('Saved');
await act(async () => { jest.advanceTimersByTime(5000); });
expect(screen.getByRole('status')).toHaveTextContent('Saved');
} finally { jest.useRealTimers(); }
});
105 changes: 105 additions & 0 deletions frontend/src/v2/__tests__/V2AgentRuntimeGrants.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,108 @@ test('does not offer revoke when the server says this viewer cannot revoke', asy
expect(await screen.findByText(/Work · Granted by Owner/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull();
});

test('a failed grant load is one row with Try again, which reloads the list', async () => {
let fail = true;
axios.get.mockImplementation((url) => {
if (url.includes('/agent-binding/placement')) return Promise.resolve({ data: { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] } });
if (url.includes('/grants') && fail) return Promise.reject(new Error('offline'));
return Promise.resolve({ data: response });
});
renderSection();
const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section');
expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not load connector grants.');
fail = false;
fireEvent.click(within(grantsSection).getByRole('button', { name: 'Try again' }));
expect(await within(grantsSection).findByText(/Work · Granted by Owner/)).toBeInTheDocument();
expect(within(grantsSection).queryByRole('alert')).toBeNull();
});

test('a failed revoke says so above the rows, keeps the grant revocable and offers no reload', async () => {
axios.post.mockRejectedValue(new Error('denied'));
renderSection();
await screen.findByText(/Work · Granted by Owner/);
fireEvent.click(screen.getByRole('button', { name: 'Revoke' }));
fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' }));
const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section');
expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not revoke the grant.');
expect(within(grantsSection).queryByRole('button', { name: 'Try again' })).toBeNull();
expect(within(grantsSection).getByRole('button', { name: 'Yes, revoke it' })).toBeEnabled();
});

test('the inline confirm swaps back to Revoke on Cancel, and the title counts active and ended grants', async () => {
axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement')
? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] }
: { grants: [...response.grants, { ...response.grants[0], grantId: 'grant-2', connector: 'sentry', revokedAt: '2026-10-20T00:00:00Z' }] } }));
renderSection();
expect(await screen.findByText('1 active · 1 ended')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Revoke' })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'Revoke' }));
expect(screen.queryByRole('button', { name: 'Revoke' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByRole('button', { name: 'Revoke' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Yes, revoke it' })).toBeNull();
expect(axios.post).not.toHaveBeenCalled();
});

test('names a grant by its connector brand, not its id', async () => {
axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement')
? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] }
: { grants: [{ ...response.grants[0], connector: 'github', tools: ['github.list_issues'] }] } }));
renderSection();
expect(await screen.findByText('GitHub')).toBeInTheDocument();
});

test('Connector grants is one heading, named by its title and not its count', async () => {
renderSection();
await screen.findByText('1 active');
const headings = screen.getAllByRole('heading').filter((node) => node.textContent.includes('Connector grants'));
expect(headings).toHaveLength(1);
expect(headings[0]).toHaveClass('gtitle');
expect(screen.getByRole('heading', { name: 'Connector grants' })).toBe(headings[0]);
});

test('each grant shows its tools as text, so two grants for one connector are told apart', async () => {
axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement')
? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] }
: { grants: [response.grants[0], { ...response.grants[0], grantId: 'grant-2', tools: ['linear.create_issue', 'linear.update_issue'] }] } }));
renderSection();
expect(await screen.findByText('linear.list_issues')).toBeVisible();
expect(screen.getByText('linear.create_issue, linear.update_issue')).toBeVisible();
});

test('a revoke error clears on Cancel and when a new confirm opens', async () => {
axios.post.mockRejectedValue(new Error('denied'));
renderSection();
await screen.findByText(/Work · Granted by Owner/);
const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section');
fireEvent.click(screen.getByRole('button', { name: 'Revoke' }));
fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' }));
expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not revoke the grant.');
fireEvent.click(within(grantsSection).getByRole('button', { name: 'Cancel' }));
expect(within(grantsSection).queryByRole('alert')).toBeNull();
});

test('opening another grant\'s confirm clears the last revoke error', async () => {
axios.post.mockRejectedValue(new Error('denied'));
axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement')
? { boundMachineId: null, requestedMachineId: null, boundMachineRevoked: false, activeMachines: [] }
: { grants: [response.grants[0], { ...response.grants[0], grantId: 'grant-2', connector: 'sentry', tools: ['sentry.list_issues'] }] } }));
renderSection();
await screen.findByText('sentry.list_issues');
const grantsSection = screen.getByRole('heading', { name: 'Connector grants' }).closest('section');
fireEvent.click(screen.getAllByRole('button', { name: 'Revoke' })[0]);
fireEvent.click(screen.getByRole('button', { name: 'Yes, revoke it' }));
expect(await within(grantsSection).findByRole('alert')).toHaveTextContent('Could not revoke the grant.');
fireEvent.click(screen.getByRole('button', { name: 'Revoke' }));
expect(within(grantsSection).queryByRole('alert')).toBeNull();
});

test('a revoked machine and a requested one both show', async () => {
axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/agent-binding/placement')
? { boundMachineId: null, requestedMachineId: 'new', boundMachineRevoked: true,
activeMachines: [{ machineId: 'new', name: 'New Mac' }] } : response }));
renderSection();
expect(await screen.findByText('This computer’s key was revoked. Choose another computer to run this agent.')).toBeInTheDocument();
expect(screen.getByText('Waiting for New Mac to start this agent')).toBeInTheDocument();
});
19 changes: 19 additions & 0 deletions frontend/src/v2/__tests__/V2AgentSettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,3 +661,22 @@ test('key polling pauses while hidden and re-reads on focus', async () => {
hidden.mockRestore();
} finally { view.unmount(); jest.useRealTimers(); }
});

test('a name outside 1–80 characters is refused under the field; other refusals sit by Save', async () => {
renderPage();
const save = await screen.findByRole('button', { name: 'Save identity' });
const name = screen.getByRole('textbox', { name: 'Display name' });
fireEvent.change(name, { target: { value: ' ' } });
fireEvent.click(save);
const error = await screen.findByText('Enter a display name of 1–80 characters.');
expect(name).toHaveAttribute('aria-invalid', 'true');
expect(name).toHaveAttribute('aria-describedby', error.id);
expect(axios.patch).not.toHaveBeenCalled();

axios.patch.mockRejectedValue({ response: { status: 403, data: {} } });
fireEvent.change(name, { target: { value: 'Maya 2' } });
fireEvent.click(save);
await screen.findByText("You don't have permission to change this agent's identity.");
expect(name).not.toHaveAttribute('aria-invalid');
expect(screen.queryByText('Enter a display name of 1–80 characters.')).toBeNull();
});
48 changes: 46 additions & 2 deletions frontend/src/v2/__tests__/V2RuntimeControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test('missing model inventory permits text, but missing effort inventory never p
expect(screen.getByText(/max stays stored, not sent/)).toBeInTheDocument();
expect(screen.queryByText(/stored choice unsupported/)).toBeNull();
expect(screen.getByLabelText('effort')).not.toHaveClass('is-unsupported');
const effortRow = screen.getByLabelText('effort').closest('.v2-runtime-controls__row')!;
const effortRow = screen.getByLabelText('effort').closest('.lrow')!;
expect(within(effortRow as HTMLElement).queryByLabelText('pending next spawn')).toBeNull();
fireEvent.change(screen.getByLabelText('model'), { target: { value: 'new-unlisted-model' } });
fireEvent.click(screen.getByRole('button', { name: 'Save runtime' }));
Expand Down Expand Up @@ -70,7 +70,7 @@ test('OpenCode provider qualification alone does not leave a permanent pending m
inventory: [{ harness: 'opencode', installed: true, version: '1', models: null, efforts: [] }],
observation: { ...runtime.observation, lastSpawn: { ...receipt, harness: 'opencode', model: 'explicit-provider/saved-model', effort: null } },
}} onSave={jest.fn()} />);
const modelRow = screen.getByLabelText('model').closest('.v2-runtime-controls__row')!;
const modelRow = screen.getByLabelText('model').closest('.lrow')!;
expect(within(modelRow as HTMLElement).queryByLabelText('pending next spawn')).toBeNull();
expect(screen.getByText('explicit-provider/saved-model')).toBeInTheDocument();
});
Expand All @@ -82,3 +82,47 @@ test('read-only inventory mismatch preserves the saved model without claiming a
expect(screen.queryByText(/saved-model · stored/)).toBeNull();
expect(screen.queryByText(/stored choice unsupported/)).toBeNull();
});

test('a dot marks only the rows that change on the next spawn', () => {
render(<Controls runtime={{ ...runtime, effort: 'high', observation: { ...runtime.observation,
lastSpawn: { ...receipt, model: 'older-model' } } }} onSave={jest.fn()} />);
const row = (field: string) => screen.getByLabelText(field).closest('.lrow') as HTMLElement;
expect(within(row('model')).getByRole('img', { name: 'pending next spawn' })).toBeInTheDocument();
expect(within(row('harness')).queryByRole('img', { name: 'pending next spawn' })).toBeNull();
expect(within(row('effort')).queryByRole('img', { name: 'pending next spawn' })).toBeNull();
});

test('a set key rests as a row; a failed save keeps the entry open, empty, with Save disabled', async () => {
mockApi.get.mockResolvedValue({ set: true, setAt: at, setBy: 'owner', lastUsedAt: null });
mockApi.put.mockRejectedValue(new Error('proxy'));
render(<Key name="seat" instanceId="default" editable capability={{ available: true, provider: 'anthropic' }} harness="Claude Code" />);
expect(await screen.findByText('never shown again')).toBeInTheDocument();
expect(screen.getByText('key for anthropic')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Replace key' }));
fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'sk-private-test-value' } });
fireEvent.click(screen.getByRole('button', { name: 'Save key' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Could not update the key. Enter it again to retry.');
expect(screen.getByLabelText('API key')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Save key' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
});

test('a row with no confirmation reads "unconfirmed", never "confirmed unconfirmed"', () => {
render(<Controls runtime={runtime} onSave={jest.fn()} />);
const row = screen.getByLabelText('model').closest('.lrow') as HTMLElement;
expect(row.textContent).toContain('last spawn actual-model · unconfirmed');
expect(row.textContent).not.toContain('confirmed unconfirmed');
});
test('a set key keeps its date and Remove when the harness cannot take a key, and while replacing it', async () => {
mockApi.get.mockResolvedValue({ set: true, setAt: at, setBy: 'owner', lastUsedAt: null });
const view = render(<Key name="seat" instanceId="default" editable capability={{ available: false, provider: null }} harness="Hermes" />);
expect(await screen.findByText(/^set · /)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
view.unmount();
render(<Key name="seat" instanceId="default" editable capability={{ available: true, provider: 'anthropic' }} harness="Claude Code" />);
fireEvent.click(await screen.findByRole('button', { name: 'Replace key' }));
expect(screen.getByLabelText('API key')).toBeInTheDocument();
expect(screen.getByText(/^set · /)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
});
Loading
Loading