Scope skills and memory to request profile

This commit is contained in:
ekko
2026-05-24 08:59:21 +08:00
committed by ekko
parent 4db3940e65
commit 289a958684
10 changed files with 142 additions and 50 deletions
+1 -1
View File
@@ -90,8 +90,8 @@ describe('KanbanCreateForm', () => {
it('uses compact profile names for assignee options', () => {
const wrapper = mount(KanbanCreateForm)
expect(wrapper.text()).toContain('default')
expect(wrapper.text()).toContain('alice')
expect(wrapper.text()).not.toContain('default')
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
})
})
+3 -2
View File
@@ -224,8 +224,9 @@ describe('KanbanView', () => {
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
expect(wrapper.text()).toContain('kanban.title: Project A · kanban.stats.tasks: 2')
expect(wrapper.text()).toContain('default')
expect(wrapper.text()).toContain('alice')
const assigneeSelect = wrapper.findAll('.n-select-stub')[2]
expect(assigneeSelect.text()).toContain('alice')
expect(assigneeSelect.text()).not.toContain('default')
expect(wrapper.text()).not.toContain('kanban.detail.assignee: alice')
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
})
+12 -11
View File
@@ -8,37 +8,38 @@ test('redirects protected routes to the login screen without a token', async ({
await expect(page).toHaveURL(/#\/$/)
await expect(page.getByRole('heading', { name: 'Hermes Web UI' })).toBeVisible()
await expect(page.getByPlaceholder('Access token')).toBeVisible()
await expect(page.getByPlaceholder('Username')).toBeVisible()
await expect(page.getByPlaceholder('Password')).toBeVisible()
expect(api.unexpectedRequests).toEqual([])
})
test('rejects an invalid access token without persisting it', async ({ page }) => {
test('rejects invalid credentials without persisting a token', async ({ page }) => {
const api = await mockHermesApi(page, { tokenValidationStatus: 401 })
await page.goto('/')
await page.getByPlaceholder('Access token').fill('bad-token')
await page.getByPlaceholder('Username').fill('playwright')
await page.getByPlaceholder('Password').fill('bad-password')
await page.getByRole('button', { name: 'Login' }).click()
await expect(page.getByText('Invalid token')).toBeVisible()
await expect(page.getByText('Invalid username or password')).toBeVisible()
await expect(page).toHaveURL(/#\/$/)
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBeNull()
expect(api.unexpectedRequests).toEqual([])
})
test('validates token login through the BFF before entering the app', async ({ page }) => {
test('logs in with password through the BFF before entering the app', async ({ page }) => {
const api = await mockHermesApi(page)
await page.goto('/')
await page.getByPlaceholder('Access token').fill(TEST_ACCESS_KEY)
await page.getByPlaceholder('Username').fill('playwright')
await page.getByPlaceholder('Password').fill('correct-password')
await page.getByRole('button', { name: 'Login' }).click()
await expect(page).toHaveURL(/#\/hermes\/chat$/)
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBe(TEST_ACCESS_KEY)
const validationRequest = api.requests.find((request) => (
request.pathname === '/api/hermes/sessions' &&
request.headers.authorization === `Bearer ${TEST_ACCESS_KEY}`
))
expect(validationRequest).toBeTruthy()
const loginRequest = api.requests.find((request) => request.pathname === '/api/auth/login')
expect(loginRequest?.method).toBe('POST')
expect(loginRequest?.postData).toBe(JSON.stringify({ username: 'playwright', password: 'correct-password' }))
expect(api.unexpectedRequests).toEqual([])
})
+2 -4
View File
@@ -101,7 +101,7 @@ test('uses the newly selected profile for the next chat-run socket after profile
await page.getByTestId('profile-selector-select').click()
await expect(page.getByRole('dialog').filter({ hasText: 'research' })).toBeVisible()
const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame())
await page.locator('.profile-runtime-item').filter({ hasText: /^research/ }).getByRole('button', { name: 'Switch Profile' }).click()
await page.locator('.profile-runtime-item').filter({ hasText: /^research/ }).getByRole('button', { name: 'Switch Frontend Profile' }).click()
await reloadPromise
await page.waitForLoadState('domcontentloaded')
await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible()
@@ -115,9 +115,7 @@ test('uses the newly selected profile for the next chat-run socket after profile
expect(run.input).toBe('Use the active research profile')
expect(await page.evaluate(() => window.localStorage.getItem('hermes_active_profile_name'))).toBe('research')
const switchRequest = api.requests.find((request) => request.pathname === '/api/hermes/profiles/active')
expect(switchRequest?.method).toBe('PUT')
expect(switchRequest?.postData).toBe(JSON.stringify({ name: 'research' }))
expect(api.requests.some((request) => request.pathname === '/api/hermes/profiles/active')).toBe(false)
expect(api.unexpectedRequests).toEqual([])
})
+30 -2
View File
@@ -98,7 +98,35 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
}
if (pathname === '/api/auth/status') {
await route.fulfill(jsonResponse({ hasPasswordLogin: false, username: null }))
await route.fulfill(jsonResponse({ hasPasswordLogin: true, username: 'playwright' }))
return
}
if (pathname === '/api/auth/login') {
if (request.method() !== 'POST') {
await route.fulfill(jsonResponse({ error: 'Method not allowed' }, 405))
return
}
if (tokenValidationStatus !== 200) {
await route.fulfill(jsonResponse({ error: 'Invalid username or password' }, tokenValidationStatus))
return
}
await route.fulfill(jsonResponse({ token: TEST_ACCESS_KEY }))
return
}
if (pathname === '/api/auth/me') {
await route.fulfill(jsonResponse({
user: {
id: 1,
username: 'playwright',
role: 'super_admin',
status: 'active',
created_at: 0,
updated_at: 0,
last_login_at: 0,
},
}))
return
}
@@ -234,7 +262,7 @@ export async function authenticate(page: Page, accessKey = TEST_ACCESS_KEY, prof
await page.addInitScript((state: { storedToken: string; storedProfileName?: string }) => {
const { storedToken, storedProfileName } = state
window.localStorage.setItem('hermes_api_key', storedToken)
if (storedProfileName) {
if (storedProfileName && !window.localStorage.getItem('hermes_active_profile_name')) {
window.localStorage.setItem('hermes_active_profile_name', storedProfileName)
}
}, { storedToken: accessKey, storedProfileName: profileName })
@@ -92,6 +92,7 @@ function makeSocket() {
connected: true,
emit: vi.fn(),
join: vi.fn(),
to: vi.fn(() => ({ emit: vi.fn() })),
} as any
}
+34 -2
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn())
const mockGetActiveProfileName = vi.hoisted(() => vi.fn())
const mockGetProfileDir = vi.hoisted(() => vi.fn())
const mockUpdateConfigYamlForProfile = vi.hoisted(() => vi.fn())
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb,
@@ -9,10 +11,15 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: mockGetActiveProfileName,
getProfileDir: mockGetProfileDir,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
pinSkill: vi.fn(),
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYamlForProfile: vi.fn(),
updateConfigYamlForProfile: mockUpdateConfigYamlForProfile,
safeReadFile: vi.fn(),
extractDescription: vi.fn(),
listFilesRecursive: vi.fn(),
}))
async function loadController() {
@@ -24,6 +31,8 @@ describe('skills controller', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetActiveProfileName.mockReturnValue('default')
mockGetProfileDir.mockImplementation((profile: string) => `/tmp/hermes-${profile}`)
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => updater({}))
mockGetSkillUsageStatsFromDb.mockResolvedValue({
period_days: 7,
summary: {
@@ -56,4 +65,27 @@ describe('skills controller', () => {
expect(mockGetSkillUsageStatsFromDb).toHaveBeenCalledWith(7, undefined, 'travel')
})
it('toggles skills in the request-scoped profile config', async () => {
let updatedConfig: Record<string, any> | undefined
mockUpdateConfigYamlForProfile.mockImplementation(async (_profile: string, updater: (config: Record<string, any>) => Record<string, any>) => {
updatedConfig = await updater({ skills: { disabled: ['old-skill'] }, model: { default: 'glm-5.1' } })
return undefined
})
const { toggle } = await loadController()
const ctx: any = {
request: { body: { name: 'new-skill', enabled: false } },
state: { profile: { name: 'research' } },
body: null,
}
await toggle(ctx)
expect(mockUpdateConfigYamlForProfile).toHaveBeenCalledWith('research', expect.any(Function))
expect(updatedConfig).toEqual({
skills: { disabled: ['old-skill', 'new-skill'] },
model: { default: 'glm-5.1' },
})
expect(ctx.body).toEqual({ success: true })
})
})