Scope skills and memory to request profile
This commit is contained in:
@@ -14,14 +14,8 @@ export function withDefaultAssignee<T extends KanbanAssigneeSummary>(
|
|||||||
byAssignee: Record<string, number> = {},
|
byAssignee: Record<string, number> = {},
|
||||||
): KanbanAssigneeSummary[] {
|
): KanbanAssigneeSummary[] {
|
||||||
const defaultCount = byAssignee[DEFAULT_KANBAN_ASSIGNEE] || 0
|
const defaultCount = byAssignee[DEFAULT_KANBAN_ASSIGNEE] || 0
|
||||||
const hasDefault = assignees.some(assignee => assignee.name === DEFAULT_KANBAN_ASSIGNEE)
|
return assignees.map(assignee => {
|
||||||
const normalized = assignees.map(assignee => {
|
|
||||||
if (assignee.name !== DEFAULT_KANBAN_ASSIGNEE || assignee.counts) return assignee
|
if (assignee.name !== DEFAULT_KANBAN_ASSIGNEE || assignee.counts) return assignee
|
||||||
return { ...assignee, counts: { total: defaultCount } }
|
return { ...assignee, counts: { total: defaultCount } }
|
||||||
})
|
})
|
||||||
if (hasDefault) return normalized
|
|
||||||
return [
|
|
||||||
{ name: DEFAULT_KANBAN_ASSIGNEE, counts: { total: defaultCount } },
|
|
||||||
...normalized,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { writeFile } from 'fs/promises'
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { safeReadFile, safeStat, getHermesDir } from '../../services/config-helpers'
|
import { safeReadFile, safeStat } from '../../services/config-helpers'
|
||||||
|
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||||
|
|
||||||
|
function requestedProfile(ctx: any): string {
|
||||||
|
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestProfileDir(ctx: any): string {
|
||||||
|
return getProfileDir(requestedProfile(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
export async function get(ctx: any) {
|
export async function get(ctx: any) {
|
||||||
const hd = getHermesDir()
|
const hd = requestProfileDir(ctx)
|
||||||
const memoryPath = join(hd, 'memories', 'MEMORY.md')
|
const memoryPath = join(hd, 'memories', 'MEMORY.md')
|
||||||
const userPath = join(hd, 'memories', 'USER.md')
|
const userPath = join(hd, 'memories', 'USER.md')
|
||||||
const soulPath = join(hd, 'SOUL.md')
|
const soulPath = join(hd, 'SOUL.md')
|
||||||
@@ -30,11 +39,13 @@ export async function save(ctx: any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let filePath: string
|
let filePath: string
|
||||||
|
const hd = requestProfileDir(ctx)
|
||||||
if (section === 'soul') {
|
if (section === 'soul') {
|
||||||
filePath = join(getHermesDir(), 'SOUL.md')
|
filePath = join(hd, 'SOUL.md')
|
||||||
} else {
|
} else {
|
||||||
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
|
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
|
||||||
filePath = join(getHermesDir(), 'memories', fileName)
|
await mkdir(join(hd, 'memories'), { recursive: true })
|
||||||
|
filePath = join(hd, 'memories', fileName)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await writeFile(filePath, content, 'utf-8')
|
await writeFile(filePath, content, 'utf-8')
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { readdir, readFile } from 'fs/promises'
|
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
|
||||||
import { join, resolve } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import {
|
import {
|
||||||
readConfigYaml, updateConfigYaml,
|
readConfigYamlForProfile, updateConfigYamlForProfile,
|
||||||
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
safeReadFile, extractDescription, listFilesRecursive,
|
||||||
} from '../../services/config-helpers'
|
} from '../../services/config-helpers'
|
||||||
import { pinSkill } from '../../services/hermes/hermes-cli'
|
|
||||||
import { isPathWithin } from '../../services/hermes/hermes-path'
|
import { isPathWithin } from '../../services/hermes/hermes-path'
|
||||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
|
||||||
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||||
|
|
||||||
function requestedProfile(ctx: any): string {
|
function requestedProfile(ctx: any): string {
|
||||||
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
|
return ctx.state?.profile?.name || getActiveProfileName() || 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestProfileDir(ctx: any): string {
|
||||||
|
return getProfileDir(requestedProfile(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSkillsDir(ctx: any): string {
|
||||||
|
return join(requestProfileDir(ctx), 'skills')
|
||||||
|
}
|
||||||
|
|
||||||
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
||||||
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
@@ -242,9 +249,9 @@ async function scanSkillsDir(skillsDir: string, bundledManifest: Map<string, str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function list(ctx: any) {
|
export async function list(ctx: any) {
|
||||||
const skillsDir = join(getHermesDir(), 'skills')
|
const skillsDir = requestSkillsDir(ctx)
|
||||||
try {
|
try {
|
||||||
const config = await readConfigYaml()
|
const config = await readConfigYamlForProfile(requestedProfile(ctx))
|
||||||
const disabledList: string[] = config.skills?.disabled || []
|
const disabledList: string[] = config.skills?.disabled || []
|
||||||
|
|
||||||
// Read provenance sources
|
// Read provenance sources
|
||||||
@@ -304,7 +311,7 @@ export async function toggle(ctx: any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await updateConfigYaml((config) => {
|
await updateConfigYamlForProfile(requestedProfile(ctx), (config) => {
|
||||||
if (!config.skills) config.skills = {}
|
if (!config.skills) config.skills = {}
|
||||||
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
|
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
|
||||||
const disabled = config.skills.disabled as string[]
|
const disabled = config.skills.disabled as string[]
|
||||||
@@ -322,10 +329,11 @@ export async function toggle(ctx: any) {
|
|||||||
|
|
||||||
export async function listFiles(ctx: any) {
|
export async function listFiles(ctx: any) {
|
||||||
const { category, skill } = ctx.params
|
const { category, skill } = ctx.params
|
||||||
const hd = getHermesDir()
|
const profileDir = requestProfileDir(ctx)
|
||||||
const skillsDir = join(hd, 'skills', category)
|
const profileSkillsDir = join(profileDir, 'skills')
|
||||||
|
const skillsDir = join(profileSkillsDir, category)
|
||||||
if (category === 'misc') {
|
if (category === 'misc') {
|
||||||
const skillDir = join(hd, 'skills', skill)
|
const skillDir = join(profileSkillsDir, skill)
|
||||||
try {
|
try {
|
||||||
const allFiles = await listFilesRecursive(skillDir, '')
|
const allFiles = await listFilesRecursive(skillDir, '')
|
||||||
const files = allFiles.filter((f: any) => f.path !== 'SKILL.md')
|
const files = allFiles.filter((f: any) => f.path !== 'SKILL.md')
|
||||||
@@ -355,14 +363,14 @@ export async function listFiles(ctx: any) {
|
|||||||
|
|
||||||
export async function readFile_(ctx: any) {
|
export async function readFile_(ctx: any) {
|
||||||
const filePath = (ctx.params as any).path
|
const filePath = (ctx.params as any).path
|
||||||
const hd = getHermesDir()
|
const profileSkillsDir = join(requestProfileDir(ctx), 'skills')
|
||||||
// Handle 'misc' category: real skill dir is skills/<skill>, not skills/misc/<skill>
|
// Handle 'misc' category: real skill dir is skills/<skill>, not skills/misc/<skill>
|
||||||
let realPath = filePath
|
let realPath = filePath
|
||||||
if (filePath.startsWith('misc/')) {
|
if (filePath.startsWith('misc/')) {
|
||||||
realPath = filePath.slice(5)
|
realPath = filePath.slice(5)
|
||||||
}
|
}
|
||||||
const fullPath = resolve(join(hd, 'skills', realPath))
|
const fullPath = resolve(join(profileSkillsDir, realPath))
|
||||||
if (!isPathWithin(fullPath, join(hd, 'skills'))) {
|
if (!isPathWithin(fullPath, profileSkillsDir)) {
|
||||||
ctx.status = 403
|
ctx.status = 403
|
||||||
ctx.body = { error: 'Access denied' }
|
ctx.body = { error: 'Access denied' }
|
||||||
return
|
return
|
||||||
@@ -376,7 +384,7 @@ export async function readFile_(ctx: any) {
|
|||||||
const category = parts[0]
|
const category = parts[0]
|
||||||
const skillName = parts[1]
|
const skillName = parts[1]
|
||||||
const restPath = parts.slice(2).join('/')
|
const restPath = parts.slice(2).join('/')
|
||||||
const catDir = join(hd, 'skills', category)
|
const catDir = join(profileSkillsDir, category)
|
||||||
const skillDir = await findSkillDirByName(catDir, skillName)
|
const skillDir = await findSkillDirByName(catDir, skillName)
|
||||||
if (skillDir) {
|
if (skillDir) {
|
||||||
const resolvedPath = resolve(join(skillDir, restPath))
|
const resolvedPath = resolve(join(skillDir, restPath))
|
||||||
@@ -396,6 +404,24 @@ export async function readFile_(ctx: any) {
|
|||||||
ctx.body = { content }
|
ctx.body = { content }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updatePinnedSkill(skillsDir: string, name: string, pinned: boolean): Promise<void> {
|
||||||
|
await mkdir(skillsDir, { recursive: true })
|
||||||
|
const usagePath = join(skillsDir, '.usage.json')
|
||||||
|
let usage: Record<string, any> = {}
|
||||||
|
const raw = await safeReadFile(usagePath)
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) usage = parsed
|
||||||
|
} catch { /* rewrite malformed usage file with the requested pin state */ }
|
||||||
|
}
|
||||||
|
const current = usage[name]
|
||||||
|
usage[name] = current && typeof current === 'object' && !Array.isArray(current)
|
||||||
|
? { ...current, pinned }
|
||||||
|
: { patch_count: 0, use_count: 0, view_count: 0, pinned }
|
||||||
|
await writeFile(usagePath, `${JSON.stringify(usage, null, 2)}\n`, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
export async function pin_(ctx: any) {
|
export async function pin_(ctx: any) {
|
||||||
const { name, pinned } = ctx.request.body as { name?: string; pinned?: boolean }
|
const { name, pinned } = ctx.request.body as { name?: string; pinned?: boolean }
|
||||||
if (!name || typeof pinned !== 'boolean') {
|
if (!name || typeof pinned !== 'boolean') {
|
||||||
@@ -404,7 +430,7 @@ export async function pin_(ctx: any) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await pinSkill(name, pinned)
|
await updatePinnedSkill(requestSkillsDir(ctx), name, pinned)
|
||||||
ctx.body = { success: true }
|
ctx.body = { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ describe('KanbanCreateForm', () => {
|
|||||||
it('uses compact profile names for assignee options', () => {
|
it('uses compact profile names for assignee options', () => {
|
||||||
const wrapper = mount(KanbanCreateForm)
|
const wrapper = mount(KanbanCreateForm)
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('default')
|
|
||||||
expect(wrapper.text()).toContain('alice')
|
expect(wrapper.text()).toContain('alice')
|
||||||
|
expect(wrapper.text()).not.toContain('default')
|
||||||
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -224,8 +224,9 @@ describe('KanbanView', () => {
|
|||||||
|
|
||||||
expect(wrapper.text()).toContain('kanban.title: Default · kanban.stats.tasks: 0')
|
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('kanban.title: Project A · kanban.stats.tasks: 2')
|
||||||
expect(wrapper.text()).toContain('default')
|
const assigneeSelect = wrapper.findAll('.n-select-stub')[2]
|
||||||
expect(wrapper.text()).toContain('alice')
|
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('kanban.detail.assignee: alice')
|
||||||
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
expect(wrapper.text()).not.toContain('alice · kanban.stats.tasks')
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-11
@@ -8,37 +8,38 @@ test('redirects protected routes to the login screen without a token', async ({
|
|||||||
|
|
||||||
await expect(page).toHaveURL(/#\/$/)
|
await expect(page).toHaveURL(/#\/$/)
|
||||||
await expect(page.getByRole('heading', { name: 'Hermes Web UI' })).toBeVisible()
|
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([])
|
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 })
|
const api = await mockHermesApi(page, { tokenValidationStatus: 401 })
|
||||||
|
|
||||||
await page.goto('/')
|
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 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).toHaveURL(/#\/$/)
|
||||||
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBeNull()
|
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBeNull()
|
||||||
expect(api.unexpectedRequests).toEqual([])
|
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)
|
const api = await mockHermesApi(page)
|
||||||
|
|
||||||
await page.goto('/')
|
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 page.getByRole('button', { name: 'Login' }).click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(/#\/hermes\/chat$/)
|
await expect(page).toHaveURL(/#\/hermes\/chat$/)
|
||||||
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBe(TEST_ACCESS_KEY)
|
await expect(page.evaluate(() => window.localStorage.getItem('hermes_api_key'))).resolves.toBe(TEST_ACCESS_KEY)
|
||||||
|
|
||||||
const validationRequest = api.requests.find((request) => (
|
const loginRequest = api.requests.find((request) => request.pathname === '/api/auth/login')
|
||||||
request.pathname === '/api/hermes/sessions' &&
|
expect(loginRequest?.method).toBe('POST')
|
||||||
request.headers.authorization === `Bearer ${TEST_ACCESS_KEY}`
|
expect(loginRequest?.postData).toBe(JSON.stringify({ username: 'playwright', password: 'correct-password' }))
|
||||||
))
|
|
||||||
expect(validationRequest).toBeTruthy()
|
|
||||||
expect(api.unexpectedRequests).toEqual([])
|
expect(api.unexpectedRequests).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 page.getByTestId('profile-selector-select').click()
|
||||||
await expect(page.getByRole('dialog').filter({ hasText: 'research' })).toBeVisible()
|
await expect(page.getByRole('dialog').filter({ hasText: 'research' })).toBeVisible()
|
||||||
const reloadPromise = page.waitForEvent('framenavigated', frame => frame === page.mainFrame())
|
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 reloadPromise
|
||||||
await page.waitForLoadState('domcontentloaded')
|
await page.waitForLoadState('domcontentloaded')
|
||||||
await expect(page.getByTestId('profile-selector-select').filter({ hasText: 'research' })).toBeVisible()
|
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(run.input).toBe('Use the active research profile')
|
||||||
expect(await page.evaluate(() => window.localStorage.getItem('hermes_active_profile_name'))).toBe('research')
|
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(api.requests.some((request) => request.pathname === '/api/hermes/profiles/active')).toBe(false)
|
||||||
expect(switchRequest?.method).toBe('PUT')
|
|
||||||
expect(switchRequest?.postData).toBe(JSON.stringify({ name: 'research' }))
|
|
||||||
expect(api.unexpectedRequests).toEqual([])
|
expect(api.unexpectedRequests).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+30
-2
@@ -98,7 +98,35 @@ export async function mockHermesApi(page: Page, options: MockHermesApiOptions =
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === '/api/auth/status') {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +262,7 @@ export async function authenticate(page: Page, accessKey = TEST_ACCESS_KEY, prof
|
|||||||
await page.addInitScript((state: { storedToken: string; storedProfileName?: string }) => {
|
await page.addInitScript((state: { storedToken: string; storedProfileName?: string }) => {
|
||||||
const { storedToken, storedProfileName } = state
|
const { storedToken, storedProfileName } = state
|
||||||
window.localStorage.setItem('hermes_api_key', storedToken)
|
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)
|
window.localStorage.setItem('hermes_active_profile_name', storedProfileName)
|
||||||
}
|
}
|
||||||
}, { storedToken: accessKey, storedProfileName: profileName })
|
}, { storedToken: accessKey, storedProfileName: profileName })
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ function makeSocket() {
|
|||||||
connected: true,
|
connected: true,
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
join: vi.fn(),
|
join: vi.fn(),
|
||||||
|
to: vi.fn(() => ({ emit: vi.fn() })),
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn())
|
const mockGetSkillUsageStatsFromDb = vi.hoisted(() => vi.fn())
|
||||||
const mockGetActiveProfileName = 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', () => ({
|
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||||
getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb,
|
getSkillUsageStatsFromDb: mockGetSkillUsageStatsFromDb,
|
||||||
@@ -9,10 +11,15 @@ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
|||||||
|
|
||||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||||
getActiveProfileName: mockGetActiveProfileName,
|
getActiveProfileName: mockGetActiveProfileName,
|
||||||
|
getProfileDir: mockGetProfileDir,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
vi.mock('../../packages/server/src/services/config-helpers', () => ({
|
||||||
pinSkill: vi.fn(),
|
readConfigYamlForProfile: vi.fn(),
|
||||||
|
updateConfigYamlForProfile: mockUpdateConfigYamlForProfile,
|
||||||
|
safeReadFile: vi.fn(),
|
||||||
|
extractDescription: vi.fn(),
|
||||||
|
listFilesRecursive: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async function loadController() {
|
async function loadController() {
|
||||||
@@ -24,6 +31,8 @@ describe('skills controller', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockGetActiveProfileName.mockReturnValue('default')
|
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({
|
mockGetSkillUsageStatsFromDb.mockResolvedValue({
|
||||||
period_days: 7,
|
period_days: 7,
|
||||||
summary: {
|
summary: {
|
||||||
@@ -56,4 +65,27 @@ describe('skills controller', () => {
|
|||||||
|
|
||||||
expect(mockGetSkillUsageStatsFromDb).toHaveBeenCalledWith(7, undefined, 'travel')
|
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 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user