Skip to content

Commit e685b5e

Browse files
authored
fix: AgentGuard 404 in local mode, hide Settings, add rename API (#842)
* feat: UI improvements for routing, provider modal, and account preferences - Rework provider select modal: remove model subtitles, replace badge with toggle switch, add slide transitions between list/detail views, plain back arrow, add title to detail view - Fix provider icon colors (Anthropic, xAI) in dark mode - Fix encryption secret fallback for local mode (getLocalAuthSecret) - Remove Ollama from provider list - Add editable display name field in Account Preferences (local mode) - Hide email in header dropdown for local mode - Move "Connect providers" button to top-right of Routing page with primary style - Move "Disable Routing" button to left, "Setup instructions" to right - Add confirmation modal before disabling routing - Restructure deactivate routing modal with numbered steps - Update terminal code blocks with light theme support (beige background, teal prompt) - Update routing instruction modal text for clarity - Make trash/disconnect icon neutral, Update Key button primary * fix: resolve AgentGuard 404 in local mode and add agent rename API - AgentGuard now waits for mode check before rendering, preventing 404 on page refresh in local mode - Skip agent existence validation entirely in local mode (agent bootstrapped by LocalBootstrapService) - Hide Settings tab in sidebar for local mode to prevent rename/delete of the hardcoded local agent - Settings page redirects to overview if accessed directly in local mode - Add PATCH /api/v1/agents/:agentName endpoint for agent renaming with conflict detection - Add renameAgent frontend API function and wire up Settings save button * chore: add changeset for AgentGuard local mode fix and rename API * fix: UI polish for modals — field spacing, button alignment, provider card border - Fix modal field spacing: use label margin-top as single source of inter-field gaps - Remove edit icon from Change button in email provider modal - Rename Update Key to Change and switch to outline style in provider modal - Fix provider card border from 2px to 1px in email provider picker - Align key-row button and disconnect icon heights to match input (2.25rem) - Fix masked-key padding to 0 1px 0 14px - Hide Settings link in sidebar for local mode (+ update Sidebar tests) * fix: update tests to match current UI (toggle switches, confirm dialog, 8 providers) - providers.test: expect 8 providers (Ollama removed) - ProviderSelectModal.test: replace Connected/Not connected badge tests with toggle switch assertions, remove Ollama tests - Routing.test: click confirm Disable button in deactivateAllProviders tests - Modal field spacing and button fixes from previous commit * test: add coverage for crypto.util, routing.service.getKeyPrefix, and aggregation.renameAgent - crypto.util.spec: 15 tests covering getEncryptionSecret (env vars, local mode, error), encrypt/decrypt round-trip, isEncrypted - routing.service.spec: 5 tests for getKeyPrefix (null input, decrypt success, custom length, decrypt failure) - aggregation.service.spec: 3 tests for renameAgent (not found, conflict, transactional rename across 6 tables)
1 parent 58310e0 commit e685b5e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1593
-379
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"manifest": patch
3+
---
4+
5+
fix: resolve AgentGuard 404 on page refresh in local mode, hide Settings tab in local mode, and add agent rename API endpoint

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/backend/src/analytics/controllers/agents.controller.spec.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe('AgentsController', () => {
2222
let mockRotateKey: jest.Mock;
2323
let mockConfigGet: jest.Mock;
2424
let mockDeleteAgent: jest.Mock;
25+
let mockRenameAgent: jest.Mock;
2526

2627
const origMode = process.env['MANIFEST_MODE'];
2728

@@ -39,6 +40,7 @@ describe('AgentsController', () => {
3940
mockRotateKey = jest.fn().mockResolvedValue({ apiKey: 'mnfst_new_key_123' });
4041
mockConfigGet = jest.fn().mockReturnValue('');
4142
mockDeleteAgent = jest.fn().mockResolvedValue(undefined);
43+
mockRenameAgent = jest.fn().mockResolvedValue(undefined);
4244

4345
const module: TestingModule = await Test.createTestingModule({
4446
imports: [CacheModule.register()],
@@ -50,7 +52,7 @@ describe('AgentsController', () => {
5052
},
5153
{
5254
provide: AggregationService,
53-
useValue: { deleteAgent: mockDeleteAgent },
55+
useValue: { deleteAgent: mockDeleteAgent, renameAgent: mockRenameAgent },
5456
},
5557
{
5658
provide: ApiKeyGeneratorService,
@@ -125,6 +127,14 @@ describe('AgentsController', () => {
125127
expect(mockRotateKey).toHaveBeenCalledWith('u1', 'bot-1');
126128
});
127129

130+
it('renames agent and returns success', async () => {
131+
const user = { id: 'u1' };
132+
const result = await controller.renameAgent(user as never, 'bot-1', { name: 'bot-renamed' } as never);
133+
134+
expect(result).toEqual({ renamed: true, name: 'bot-renamed' });
135+
expect(mockRenameAgent).toHaveBeenCalledWith('u1', 'bot-1', 'bot-renamed');
136+
});
137+
128138
it('deletes agent and returns success', async () => {
129139
const user = { id: 'u1' };
130140
const result = await controller.deleteAgent(user as never, 'bot-1');

packages/backend/src/analytics/controllers/agents.controller.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Body, Controller, Delete, ForbiddenException, Get, Param, Post, UseInterceptors } from '@nestjs/common';
1+
import { Body, Controller, Delete, ForbiddenException, Get, Param, Patch, Post, UseInterceptors } from '@nestjs/common';
22
import { CacheTTL } from '@nestjs/cache-manager';
33
import { ConfigService } from '@nestjs/config';
44
import { TimeseriesQueriesService } from '../services/timeseries-queries.service';
@@ -7,6 +7,7 @@ import { ApiKeyGeneratorService } from '../../otlp/services/api-key.service';
77
import { CurrentUser } from '../../auth/current-user.decorator';
88
import { AuthUser } from '../../auth/auth.instance';
99
import { CreateAgentDto } from '../../common/dto/create-agent.dto';
10+
import { RenameAgentDto } from '../../common/dto/rename-agent.dto';
1011
import { UserCacheInterceptor } from '../../common/interceptors/user-cache.interceptor';
1112
import { DASHBOARD_CACHE_TTL_MS } from '../../common/constants/cache.constants';
1213
import { readLocalApiKey } from '../../common/constants/local-mode.constants';
@@ -57,6 +58,16 @@ export class AgentsController {
5758
return { apiKey: result.apiKey };
5859
}
5960

61+
@Patch('agents/:agentName')
62+
async renameAgent(
63+
@CurrentUser() user: AuthUser,
64+
@Param('agentName') agentName: string,
65+
@Body() body: RenameAgentDto,
66+
) {
67+
await this.aggregation.renameAgent(user.id, agentName, body.name);
68+
return { renamed: true, name: body.name };
69+
}
70+
6071
@Delete('agents/:agentName')
6172
async deleteAgent(@CurrentUser() user: AuthUser, @Param('agentName') agentName: string) {
6273
if (this.config.get<string>('MANIFEST_MODE') === 'local') {

packages/backend/src/analytics/services/aggregation.service.spec.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { getRepositoryToken } from '@nestjs/typeorm';
3-
import { NotFoundException } from '@nestjs/common';
3+
import { ConflictException, NotFoundException } from '@nestjs/common';
44
import { DataSource } from 'typeorm';
55
import { AggregationService } from './aggregation.service';
66
import { AgentMessage } from '../../entities/agent-message.entity';
@@ -13,6 +13,8 @@ describe('AggregationService', () => {
1313
let mockGetRawMany: jest.Mock;
1414
let mockAgentGetOne: jest.Mock;
1515
let mockAgentDelete: jest.Mock;
16+
let mockTransaction: jest.Mock;
17+
let mockAgentCreateQueryBuilder: jest.Mock;
1618

1719
beforeEach(async () => {
1820
mockGetRawOne = jest.fn().mockResolvedValue({ total: 0 });
@@ -38,6 +40,8 @@ describe('AggregationService', () => {
3840
getOne: jest.fn().mockResolvedValue(null),
3941
};
4042

43+
mockTransaction = jest.fn().mockImplementation(async (cb: Function) => cb());
44+
4145
const mockAgentQb = {
4246
select: jest.fn().mockReturnThis(),
4347
leftJoin: jest.fn().mockReturnThis(),
@@ -48,6 +52,8 @@ describe('AggregationService', () => {
4852
getMany: jest.fn().mockResolvedValue([]),
4953
};
5054

55+
mockAgentCreateQueryBuilder = jest.fn().mockReturnValue(mockAgentQb);
56+
5157
const mockTimeseries = {
5258
getHourlyTokens: jest.fn().mockResolvedValue([]),
5359
getDailyTokens: jest.fn().mockResolvedValue([]),
@@ -71,12 +77,12 @@ describe('AggregationService', () => {
7177
{
7278
provide: getRepositoryToken(Agent),
7379
useValue: {
74-
createQueryBuilder: jest.fn().mockReturnValue(mockAgentQb),
80+
createQueryBuilder: mockAgentCreateQueryBuilder,
7581
delete: mockAgentDelete,
7682
},
7783
},
7884
{ provide: TimeseriesQueriesService, useValue: mockTimeseries },
79-
{ provide: DataSource, useValue: { options: { type: 'postgres' } } },
85+
{ provide: DataSource, useValue: { options: { type: 'postgres' }, transaction: mockTransaction } },
8086
],
8187
}).compile();
8288

@@ -170,6 +176,73 @@ describe('AggregationService', () => {
170176
});
171177
});
172178

179+
describe('renameAgent', () => {
180+
it('should throw NotFoundException when agent not found', async () => {
181+
mockAgentGetOne.mockResolvedValueOnce(null);
182+
183+
await expect(
184+
service.renameAgent('test-user', 'nonexistent', 'new-name'),
185+
).rejects.toThrow(NotFoundException);
186+
});
187+
188+
it('should throw ConflictException when new name already exists', async () => {
189+
// First call: find current agent — found
190+
mockAgentGetOne.mockResolvedValueOnce({ id: 'agent-id-1', name: 'old-agent' });
191+
// Second call: check for duplicate — found
192+
mockAgentGetOne.mockResolvedValueOnce({ id: 'agent-id-2', name: 'taken-name' });
193+
194+
await expect(
195+
service.renameAgent('test-user', 'old-agent', 'taken-name'),
196+
).rejects.toThrow(ConflictException);
197+
});
198+
199+
it('should rename agent and update all related tables in a transaction', async () => {
200+
// First call: find current agent — found
201+
mockAgentGetOne.mockResolvedValueOnce({ id: 'agent-id-1', name: 'old-agent' });
202+
// Second call: check for duplicate — not found
203+
mockAgentGetOne.mockResolvedValueOnce(null);
204+
205+
const mockExecute = jest.fn().mockResolvedValue({});
206+
const mockManagerQb = {
207+
update: jest.fn().mockReturnThis(),
208+
set: jest.fn().mockReturnThis(),
209+
where: jest.fn().mockReturnThis(),
210+
execute: mockExecute,
211+
};
212+
213+
mockTransaction.mockImplementation(async (cb: Function) => {
214+
const manager = {
215+
createQueryBuilder: jest.fn().mockReturnValue(mockManagerQb),
216+
};
217+
return cb(manager);
218+
});
219+
220+
await service.renameAgent('test-user', 'old-agent', 'new-agent');
221+
222+
// Verify transaction was called
223+
expect(mockTransaction).toHaveBeenCalledTimes(1);
224+
225+
// The transaction callback should have executed:
226+
// 1 update for agents table + 5 updates for related tables = 6 total
227+
expect(mockExecute).toHaveBeenCalledTimes(6);
228+
229+
// Verify agents table update was called with agent id
230+
expect(mockManagerQb.update).toHaveBeenCalledWith('agents');
231+
expect(mockManagerQb.set).toHaveBeenCalledWith({ name: 'new-agent' });
232+
233+
// Verify all 5 related tables were updated
234+
const updateCalls = mockManagerQb.update.mock.calls.map(
235+
(c: unknown[]) => c[0],
236+
);
237+
expect(updateCalls).toContain('agents');
238+
expect(updateCalls).toContain('agent_messages');
239+
expect(updateCalls).toContain('notification_rules');
240+
expect(updateCalls).toContain('notification_logs');
241+
expect(updateCalls).toContain('token_usage_snapshots');
242+
expect(updateCalls).toContain('cost_snapshots');
243+
});
244+
});
245+
173246
describe('getMessages', () => {
174247
it('returns paginated messages with total count and models list', async () => {
175248
// count query

packages/backend/src/analytics/services/aggregation.service.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, NotFoundException } from '@nestjs/common';
1+
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
22
import { InjectRepository } from '@nestjs/typeorm';
33
import { Brackets, DataSource, Repository } from 'typeorm';
44
import { AgentMessage } from '../../entities/agent-message.entity';
@@ -142,6 +142,49 @@ export class AggregationService {
142142
await this.agentRepo.delete(agent.id);
143143
}
144144

145+
async renameAgent(userId: string, currentName: string, newName: string): Promise<void> {
146+
const agent = await this.agentRepo
147+
.createQueryBuilder('a')
148+
.leftJoin('a.tenant', 't')
149+
.where('t.name = :userId', { userId })
150+
.andWhere('a.name = :currentName', { currentName })
151+
.getOne();
152+
153+
if (!agent) {
154+
throw new NotFoundException(`Agent "${currentName}" not found`);
155+
}
156+
157+
const duplicate = await this.agentRepo
158+
.createQueryBuilder('a')
159+
.leftJoin('a.tenant', 't')
160+
.where('t.name = :userId', { userId })
161+
.andWhere('a.name = :newName', { newName })
162+
.getOne();
163+
164+
if (duplicate) {
165+
throw new ConflictException(`Agent "${newName}" already exists`);
166+
}
167+
168+
await this.dataSource.transaction(async (manager) => {
169+
await manager
170+
.createQueryBuilder()
171+
.update('agents')
172+
.set({ name: newName })
173+
.where('id = :id', { id: agent.id })
174+
.execute();
175+
176+
const tables = ['agent_messages', 'notification_rules', 'notification_logs', 'token_usage_snapshots', 'cost_snapshots'];
177+
for (const table of tables) {
178+
await manager
179+
.createQueryBuilder()
180+
.update(table)
181+
.set({ agent_name: newName })
182+
.where('agent_name = :currentName', { currentName })
183+
.execute();
184+
}
185+
});
186+
}
187+
145188
async getMessages(params: {
146189
range?: string;
147190
userId: string;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import 'reflect-metadata';
2+
import { validate } from 'class-validator';
3+
import { plainToInstance } from 'class-transformer';
4+
import { RenameAgentDto } from './rename-agent.dto';
5+
6+
describe('RenameAgentDto', () => {
7+
it('accepts valid agent names', async () => {
8+
for (const name of ['my-agent', 'agent_1', 'TestBot', 'a']) {
9+
const dto = plainToInstance(RenameAgentDto, { name });
10+
const errors = await validate(dto);
11+
expect(errors).toHaveLength(0);
12+
}
13+
});
14+
15+
it('rejects empty name', async () => {
16+
const dto = plainToInstance(RenameAgentDto, { name: '' });
17+
const errors = await validate(dto);
18+
expect(errors.length).toBeGreaterThan(0);
19+
});
20+
21+
it('rejects missing name', async () => {
22+
const dto = plainToInstance(RenameAgentDto, {});
23+
const errors = await validate(dto);
24+
expect(errors.length).toBeGreaterThan(0);
25+
});
26+
27+
it('rejects names with spaces', async () => {
28+
const dto = plainToInstance(RenameAgentDto, { name: 'has spaces' });
29+
const errors = await validate(dto);
30+
expect(errors.length).toBeGreaterThan(0);
31+
});
32+
33+
it('rejects names with special characters', async () => {
34+
const dto = plainToInstance(RenameAgentDto, { name: 'agent@home!' });
35+
const errors = await validate(dto);
36+
expect(errors.length).toBeGreaterThan(0);
37+
});
38+
39+
it('rejects names longer than 100 characters', async () => {
40+
const dto = plainToInstance(RenameAgentDto, { name: 'a'.repeat(101) });
41+
const errors = await validate(dto);
42+
expect(errors.length).toBeGreaterThan(0);
43+
});
44+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { IsString, IsNotEmpty, MinLength, MaxLength, Matches } from 'class-validator';
2+
3+
export class RenameAgentDto {
4+
@IsString()
5+
@IsNotEmpty()
6+
@MinLength(1)
7+
@MaxLength(100)
8+
@Matches(/^[a-zA-Z0-9_-]+$/, { message: 'Agent name must contain only letters, numbers, dashes, and underscores' })
9+
name!: string;
10+
}

0 commit comments

Comments
 (0)