Files
LocalAI/core/http/react-ui/e2e/agents-edit.spec.js
T
Ettore Di Giacinto 9b40c24a30 feat(agents): warn in the editor when a first grant turns off connector features
An agent with no grant list keeps its non-sensitive connector features
(trigger tools, talk-back, files from senders). Granting any one built-in
makes the list the whole answer, so the others stop working. The editor
had no grants list at all, so nobody could see this.

Add a Permissions section to the agent editor with the declared grants.
When the first grant is ticked on a saved agent that had none, a notice
names the connector grants that would turn off and a button adds them.

Also fix the files.upload text. The gate enforces it on files a sender
attaches to a message, not on the agent sending files out, so the grant
is now "Receive files".

Assisted-by: Claude:claude-sonnet-5-5 go-test playwright
2026-11-03 19:38:55 +00:00

577 lines
34 KiB
JavaScript

import { test, expect } from './coverage-fixtures.js'
import { installAgents, AGENTS, HOME_FOLDERS } from './agents-fixtures.js'
import { writeFile, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// The agent form: folds, preview, import and export, with the saved-agents API stubbed.
const fold = (page, id) => page.locator(`[data-section="${id}"]`)
test.describe('Agent form', () => {
test('creates an agent from the folds and sends the definition', async ({ page }) => {
const s = await installAgents(page)
await page.goto('/app/agents/new')
await expect(page.getByTestId('agent-edit')).toBeVisible()
await page.getByLabel('Name', { exact: false }).first().fill('Reviewer')
await page.getByLabel('Description').fill('Checks a change')
await fold(page, 'PromptsGoals').locator('.ag-fold__button').click()
await page.getByLabel('Instructions').fill('Answer in French.')
await fold(page, 'MemorySettings').locator('.ag-fold__button').click()
await page.getByRole('combobox', { name: 'Collections +' }).fill('handbook')
await page.getByRole('combobox', { name: 'Collections +' }).press('Enter')
await expect(page.getByTestId('agent-collections').getByRole('listitem')).toHaveText(['handbook'])
await fold(page, 'Skills').locator('.ag-fold__button').click()
await page.getByTestId('agent-skills-panel').getByRole('switch', { name: /citations/ }).click()
await fold(page, 'Tools').locator('.ag-fold__button').click()
await page.getByTestId('agent-tools-panel').getByRole('switch', { name: /Docs/ }).click()
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'POST').body).toEqual({
name: 'Reviewer', description: 'Checks a change', instructions: 'Answer in French.', model: '',
mcp_servers: ['connector:m1'], addons: [], collections: ['handbook'], skills: ['citations'], builtins_off: [], builtins_on: [], builtins: [], network: 'off',
})
})
test('warns when the first permission turns off the messaging defaults, and adds the missing ones in one click', async ({ page }) => {
const s = await installAgents(page)
await page.goto('/app/agents/a2/edit')
await fold(page, 'Grants').locator('.ag-fold__button').click()
await expect(page.getByTestId('agent-grants-notice')).toHaveCount(0)
await page.getByTestId('agent-grant').filter({ hasText: 'Remember things' }).getByRole('checkbox').check()
const notice = page.getByTestId('agent-grants-notice')
await expect(notice).toContainText('triggers.propose, triggers.run, talkback.channels, files.upload')
await page.getByTestId('agent-grants-add-missing').click()
await expect(notice).toHaveCount(0)
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
const put = s.calls.find(c => c.method === 'PUT')
expect(put.body.builtins.sort()).toEqual(['files.upload', 'memory.write', 'talkback.channels', 'triggers.propose', 'triggers.run'])
})
test('shows no warning for an agent that already had permissions', async ({ page }) => {
await installAgents(page, { agents: [{ ...AGENTS[1], builtins: ['memory.write'] }] })
await page.goto('/app/agents/a2/edit')
await fold(page, 'Grants').locator('.ag-fold__button').click()
await page.getByTestId('agent-grant').filter({ hasText: 'Propose triggers' }).getByRole('checkbox').check()
await expect(page.getByTestId('agent-grants-notice')).toHaveCount(0)
})
test('sets the network of the commands of unattended runs', async ({ page }) => {
const s = await installAgents(page)
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Builder')
await fold(page, 'Tools').locator('.ag-fold__button').click()
await expect(page.getByTestId('agent-network')).toHaveValue('off')
await page.getByTestId('agent-network').selectOption('registries')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'POST').body.network).toBe('registries')
})
test('needs a name, and says so on the fold', async ({ page }) => {
const s = await installAgents(page)
await page.goto('/app/agents/new')
await expect(fold(page, 'BasicInfo')).toContainText('Needs a name')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
expect(s.calls.some(c => c.method === 'POST')).toBe(false)
})
test('a starting point fills the name, the description and the instructions', async ({ page }) => {
await installAgents(page)
await page.goto('/app/agents/new')
await page.locator('[data-template="reviewer"]').click()
await expect(page.getByLabel('Name', { exact: false }).first()).toHaveValue('code-reviewer')
await expect(page.getByLabel('Instructions')).toContainText('You review code changes')
})
test('the preview shows the definition and what changed from the saved agent', async ({ page }) => {
const s = await installAgents(page)
await page.goto('/app/agents/a1/edit')
await expect(page.getByLabel('Name', { exact: false }).first()).toHaveValue('research-assistant')
await page.getByLabel('Description').fill('Searches and cites.')
await expect(fold(page, 'BasicInfo')).toContainText('1 changed')
await page.getByTestId('agent-preview-open').click()
const sheet = page.getByTestId('agent-preview')
await expect(sheet.getByTestId('agent-preview-config')).toContainText('"description": "Searches and cites."')
await sheet.getByRole('tab', { name: /Changes/ }).click()
const changes = sheet.getByTestId('agent-preview-changes')
await expect(changes).toContainText('description')
await expect(changes.locator('[data-side="before"]')).toContainText('Searches the web and writes short briefs.')
await expect(changes.locator('[data-side="after"]')).toContainText('Searches and cites.')
await sheet.getByRole('button', { name: 'Save Changes' }).click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT')).toMatchObject({ url: '/api/chats/agents/a1', body: { name: 'research-assistant', description: 'Searches and cites.', mcp_servers: ['crm', 'connector:m1'] } })
})
test('the tools list is the list of the chat: connectors by name, one that was deleted stays, and the servers are free text', async ({ page }) => {
const s = await installAgents(page)
await page.goto('/app/agents/a1/edit')
await fold(page, 'Tools').locator('.ag-fold__button').click()
const picker = page.getByTestId('agent-tools-panel')
await expect(picker.getByRole('switch', { name: /Docs/ })).toBeChecked()
await expect(picker.getByRole('switch', { name: /Parked/ })).not.toBeChecked()
await fold(page, 'MCP').locator('.ag-fold__button').click()
await expect(page.getByTestId('agent-mcp').getByRole('listitem')).toHaveText(['crm'])
await expect(page.locator('datalist option[value="mail"]')).toHaveCount(1)
await expect(picker.getByTestId('agent-tools-item').filter({ hasText: 'Parked' })).toContainText('off')
await picker.getByRole('switch', { name: /Parked/ }).click()
await page.getByRole('combobox', { name: 'MCP servers +' }).fill('mail')
await page.getByRole('combobox', { name: 'MCP servers +' }).press('Enter')
await page.getByRole('button', { name: 'Save Changes' }).first().click()
await expect.poll(() => s.calls.find(c => c.method === 'PUT')?.body.mcp_servers).toEqual(['crm', 'mail', 'connector:m1', 'connector:m2'])
})
test('every switch of the tools list says what it does, and the switches are saved with the agent', async ({ page }) => {
const s = await installAgents(page)
await page.goto('/app/agents/a1/edit')
await fold(page, 'Tools').locator('.ag-fold__button').click()
const panel = page.getByTestId('agent-tools-panel')
// The same words as the Tools menu of a chat: a title, a description, and
// the add-on's declared title instead of its id.
const rows = panel.getByTestId('agent-tools-item')
await expect(rows).toHaveCount(8)
for (const row of await rows.all()) await expect(row.getByTestId('agent-tools-blurb')).not.toBeEmpty()
await expect(panel.getByRole('switch', { name: 'Memory and profile changes' })).toBeChecked()
await expect(panel.getByRole('switch', { name: 'Office' })).toBeChecked()
await expect(panel).toContainText('Read and write documents and spreadsheets.')
await expect(panel.getByRole('switch', { name: 'Saved agents' })).not.toBeChecked()
await panel.getByRole('switch', { name: 'Triggers' }).click()
// Once off, the row also says what the model loses.
await expect(panel.getByTestId('agent-tools-item').filter({ hasText: 'Triggers' })).toContainText('The model cannot create or manage triggers.')
await panel.getByRole('switch', { name: 'Saved agents' }).click()
await page.getByRole('button', { name: 'Save Changes' }).first().click()
await expect.poll(() => s.calls.find(c => c.method === 'PUT')?.body).toMatchObject({ builtins_off: ['triggers'], builtins_on: ['saved_agents'], addons: ['office'] })
})
test('shows the reason when the server refuses the agent', async ({ page }) => {
await installAgents(page)
await page.route(/\/api\/chats\/agents(\?.*)?$/, route => (route.request().method() === 'POST'
? route.fulfill({ status: 409, json: { error: { message: 'you already have an agent with this name', type: 'conflict', code: 409 } } })
: route.fulfill({ json: { agents: [] } })))
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Dup')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page.getByText('you already have an agent with this name')).toBeVisible()
await expect(page).toHaveURL(/\/app\/agents\/new$/)
})
test('imports an exported agent and the export of an older agent through the server, and names what was dropped', async ({ page }) => {
const s = await installAgents(page)
const dir = await mkdtemp(join(tmpdir(), 'agent-import-'))
const exported = join(dir, 'new.json')
const file = { version: 1, kind: 'localai-agent', agent: { name: 'Imported', description: 'd', instructions: 'Be brief.', model: '', mcp_servers: [], addons: [], collections: ['handbook'], skills: ['citations'] } }
await writeFile(exported, JSON.stringify(file))
await page.goto('/app/agents')
await page.locator('input[type=file]').first().setInputFiles(exported)
// The server makes the agent, and the page opens it.
await expect(page).toHaveURL(/\/app\/agents\/a3$/)
await expect(page.getByRole('heading', { name: 'Imported', level: 1 })).toBeVisible()
await expect(page.getByText('Agent "Imported" imported')).toBeVisible()
expect(s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/agents/import').body).toEqual(file)
// An export of the old agents is turned into a definition first, and what
// a saved agent has no place for is named.
const legacy = join(dir, 'old.json')
await writeFile(legacy, JSON.stringify({ name: 'helper', model: 'qwen3-8b', system_prompt: 'Be kind.', selected_skills: ['citations'], actions: [{ name: 'web_search' }], enable_kb: true }))
await page.goto('/app/agents')
await page.locator('input[type=file]').first().setInputFiles(legacy)
await expect(page).toHaveURL(/\/app\/agents\/a4$/)
await expect(page.getByText('Not imported: actions, enable_kb.')).toBeVisible()
const sent = s.calls.filter(c => c.method === 'POST' && c.url === '/api/chats/agents/import')[1].body
expect(sent).toMatchObject({ name: 'helper', model: 'qwen3-8b', instructions: 'Be kind.', skills: ['citations'] })
expect(sent).not.toHaveProperty('system_prompt')
})
test('says what the server changed in an imported agent', async ({ page }) => {
await installAgents(page, { importWarnings: ['the model "model-from-elsewhere" is not available to you here, so the agent has no model'] })
const dir = await mkdtemp(join(tmpdir(), 'agent-import-'))
const path = join(dir, 'shared.json')
await writeFile(path, JSON.stringify({ version: 1, kind: 'localai-agent', agent: { name: 'Shared', instructions: 'Hi', model: 'model-from-elsewhere' } }))
await page.goto('/app/agents')
await page.locator('input[type=file]').first().setInputFiles(path)
await expect(page).toHaveURL(/\/app\/agents\/a3$/)
await expect(page.getByText(/is not available to you here/)).toBeVisible()
})
test('a file that is not an agent is refused with a message', async ({ page }) => {
const s = await installAgents(page)
const dir = await mkdtemp(join(tmpdir(), 'agent-import-'))
const bad = join(dir, 'bad.json')
await writeFile(bad, JSON.stringify({ hello: 'world' }))
await page.goto('/app/agents')
await page.locator('input[type=file]').first().setInputFiles(bad)
// The server reads the file and refuses it; nothing is made.
await expect(page.getByText(/the body is not a valid agent file/)).toBeVisible()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.agents).toHaveLength(2)
})
test('the Export fold downloads the definition of a saved agent', async ({ page }) => {
await installAgents(page)
await page.goto('/app/agents/a1/edit')
await fold(page, 'export').locator('.ag-fold__button').click()
const download = page.waitForEvent('download')
await page.getByRole('button', { name: 'Export agent' }).click()
expect((await download).suggestedFilename()).toBe('research-assistant.json')
})
})
// The folder of an agent: the Files fold, the picker, the mode and the
// missing state. The listing follows the folder asked for (HOME_FOLDERS).
const ACME_READ = { path: '/Projects/Acme', name: 'Acme', mode: 'read', state: 'ok' }
const withFolder = (folder) => AGENTS.map(a => (a.id === 'a2' ? { ...a, folder } : a))
test.describe('Agent folder', () => {
test('choose a folder and save shows it on the card', async ({ page }) => {
const s = await installAgents(page, { files: HOME_FOLDERS })
await page.goto('/app/agents/a2/edit')
await expect(fold(page, 'Files')).toContainText('All my files')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder-scope-one').click()
// Nothing chosen yet: the fold needs the person, and Save says so.
await expect(fold(page, 'Files')).toContainText('Choose a folder, or pick All my files.')
await page.getByRole('button', { name: 'Save changes' }).first().click()
expect(s.calls.some(c => c.method === 'PUT')).toBe(false)
await page.getByTestId('agent-folder-choose').click()
const dialog = page.getByTestId('agent-folder-dialog')
await expect(dialog.getByTestId('folder-picker')).toBeVisible()
await dialog.getByRole('button', { name: 'Projects' }).click()
await dialog.getByRole('button', { name: 'Acme' }).click()
// An empty folder says so, and is still the one chosen.
await expect(dialog).toContainText('No folders here')
await dialog.getByTestId('agent-folder-use').click()
await expect(page.getByTestId('agent-folder-path')).toHaveText('/Projects/Acme')
await expect(fold(page, 'Files')).toContainText('/Projects/Acme, can read')
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body.folders).toEqual([{ path: '/Projects/Acme', mode: 'read' }])
await expect(page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip')).toContainText('Acme, read only')
})
test('read only shows the lock chip, and the mode switch sends write', async ({ page }) => {
const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) })
await page.goto('/app/agents/a2/edit')
const chip = page.getByTestId('agent-facts').getByTestId('agent-folder-chip')
await expect(chip).toContainText('Acme, read only')
await expect(chip.locator('[data-icon="lock"]')).toHaveCount(1)
await page.goto('/app/agents/a2/edit')
await fold(page, 'Files').locator('.ag-fold__button').click()
await expect(page.getByTestId('agent-folder-mode-0-read')).toHaveAttribute('aria-pressed', 'true')
await page.getByTestId('agent-folder-mode-0-write').click()
await expect(fold(page, 'Files')).toContainText('1 changed')
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body.folders).toEqual([{ path: '/Projects/Acme', mode: 'write' }])
const row = page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip')
await expect(row).toContainText('Acme, read and change')
await expect(row.locator('[data-icon="folder"]')).toHaveCount(1)
})
test('an unchanged folder is not sent, and All my files clears it', async ({ page }) => {
const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) })
await page.goto('/app/agents/a2/edit')
await page.getByLabel('Description').fill('Reviews and lists.')
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body).not.toHaveProperty('folders')
await page.goto('/app/agents/a2/edit')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder-scope-all').click()
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.filter(c => c.method === 'PUT')[1].body.folders).toEqual([])
await expect(page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip')).toHaveCount(0)
})
test('a missing folder shows the inline error and the warning chip', async ({ page }) => {
const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder({ mode: 'write', state: 'missing' }) })
await page.goto('/app/agents')
const chip = page.locator('[data-agent="code-reviewer"]').getByTestId('agent-folder-chip')
await expect(chip).toHaveText('Folder missing')
await page.goto('/app/agents/a2/edit')
await expect(page.getByTestId('agent-folder-warning')).toContainText('This folder is gone. Choose another folder to use this agent.')
await page.getByTestId('agent-facts').getByTestId('agent-folder-chip').click()
await expect(page).toHaveURL(/\/app\/agents\/a2\/edit$/)
// The fold says so; the save of another field keeps working and keeps it.
await expect(fold(page, 'Files')).toContainText('Folder missing')
await fold(page, 'Files').locator('.ag-fold__button').click()
await expect(page.getByTestId('agent-folder-missing')).toContainText('This folder is gone. Choose another folder to use this agent.')
await page.getByLabel('Description').fill('Still saves.')
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body).not.toHaveProperty('folders')
// Choosing another folder sends it with the mode the agent had.
await page.goto('/app/agents/a2/edit')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder-missing').getByRole('button', { name: 'Choose another folder' }).click()
await page.getByTestId('agent-folder-dialog').getByRole('button', { name: 'Projects' }).click()
await page.getByTestId('agent-folder-use').click()
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.filter(c => c.method === 'PUT')[1].body.folders).toEqual([{ path: '/Projects', mode: 'write' }])
})
test('the picker cannot choose Chats or LocalAI, and all the files can be chosen', async ({ page }) => {
const s = await installAgents(page, { files: HOME_FOLDERS })
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Reader')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder-scope-one').click()
await page.getByTestId('agent-folder-choose').click()
const dialog = page.getByTestId('agent-folder-dialog')
for (const name of ['Chats', 'LocalAI']) {
const row = dialog.getByRole('button', { name: new RegExp(`^${name}`) })
await expect(row).toBeDisabled()
await expect(row).toContainText('LocalAI manages this folder')
}
await expect(dialog.getByRole('button', { name: /^Projects/ })).toBeEnabled()
// A file is no folder: the picker lists folders only.
await expect(dialog).not.toContainText('notes.txt')
await dialog.getByTestId('agent-folder-use').click()
await expect(page.getByTestId('agent-folder-path')).toHaveText('Home')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'POST').body.folders).toEqual([{ path: '/', mode: 'read' }])
})
test('several folders: add, per-row mode, remove, and the cap of eight', async ({ page }) => {
const names = ['DirA', 'DirB', 'DirC', 'DirD', 'DirE', 'DirF', 'DirG', 'DirH']
const files = { ...HOME_FOLDERS }
files['/'] = [...HOME_FOLDERS['/'], ...names.map(n => ({ path: `/${n}`, name: n, kind: 'dir' }))]
for (const n of names) files[`/${n}`] = []
const s = await installAgents(page, { files })
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Many')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder-scope-one').click()
const pick = async (name, choose) => {
await choose.click()
const dialog = page.getByTestId('agent-folder-dialog')
await dialog.getByRole('button', { name: new RegExp(`^${name}`) }).click()
await dialog.getByTestId('agent-folder-use').click()
}
await pick('DirA', page.getByTestId('agent-folder-choose'))
await page.getByTestId('agent-folder-mode-0-write').click()
await page.getByTestId('agent-folder-add').click()
{
const d = page.getByTestId('agent-folder-dialog')
await d.getByRole('button', { name: /^DirB/ }).click()
await d.getByTestId('agent-folder-use').click()
}
await expect(page.getByTestId('agent-folder-row')).toHaveCount(2)
await expect(page.getByTestId('agent-folder-count')).toHaveText('2 of 8 folders')
// The second row keeps its own mode.
await expect(page.getByTestId('agent-folder-mode-1-read')).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByTestId('agent-folder-mode-0-write')).toHaveAttribute('aria-pressed', 'true')
for (const n of ['DirC', 'DirD', 'DirE', 'DirF', 'DirG', 'DirH']) {
await page.getByTestId('agent-folder-add').click()
const d = page.getByTestId('agent-folder-dialog')
await d.getByRole('button', { name: new RegExp(`^${n}`) }).click()
await d.getByTestId('agent-folder-use').click()
}
await expect(page.getByTestId('agent-folder-row')).toHaveCount(8)
await expect(page.getByTestId('agent-folder-add')).toBeDisabled()
await page.getByTestId('agent-folder-remove').last().click()
await expect(page.getByTestId('agent-folder-add')).toBeEnabled()
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
const body = s.calls.find(c => c.method === 'POST').body
expect(body.folders).toHaveLength(7)
expect(body.folders[0]).toEqual({ path: '/DirA', mode: 'write' })
expect(body.folders[1]).toEqual({ path: '/DirB', mode: 'read' })
await expect(page.locator('[data-agent="Many"]').getByTestId('agent-folder-chip')).toContainText('+6')
})
test('a row added and cancelled goes away, and a folder inside another shows the outer mode', async ({ page }) => {
await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) })
await page.goto('/app/agents/a2/edit')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder-add').click()
await page.getByTestId('agent-folder-dialog').getByRole('button', { name: 'Cancel' }).first().click()
await expect(page.getByTestId('agent-folder-row')).toHaveCount(1)
// /Projects holds /Projects/Acme: the inner folder works at the outer mode.
await page.getByTestId('agent-folder-add').click()
const dialog = page.getByTestId('agent-folder-dialog')
await dialog.getByRole('button', { name: /^Projects/ }).click()
await dialog.getByTestId('agent-folder-use').click()
await page.getByTestId('agent-folder-mode-1-write').click()
await expect(page.getByTestId('agent-folder-effective')).toHaveText('Inside a folder that can be changed, so this one can be changed too.')
})
test('an agent with several folders reads them back, and a not-chosen row blocks the save', async ({ page }) => {
const rows = [ACME_READ, { path: '/Projects/Beta', name: 'Beta', mode: 'write', state: 'ok', effective: 'write' }]
const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ).map(a => (a.id === 'a2' ? { ...a, folders: rows } : a)) })
await page.goto('/app/agents/a2/edit')
await expect(fold(page, 'Files')).toContainText('2 folders')
await fold(page, 'Files').locator('.ag-fold__button').click()
await expect(page.getByTestId('agent-folder-row')).toHaveCount(2)
await page.getByTestId('agent-folder-add').click()
await page.getByTestId('agent-folder-dialog').getByRole('button', { name: 'Cancel' }).first().click()
await page.getByTestId('agent-folder-remove').first().click()
await page.getByRole('button', { name: 'Save changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body.folders).toEqual([{ path: '/Projects/Beta', mode: 'write' }])
})
test('a folder that cannot be read says so and tries again', async ({ page }) => {
let fail = true
await installAgents(page, { files: HOME_FOLDERS })
await page.route('**/api/chats/files*', route => {
if (fail) { fail = false; return route.fulfill({ status: 500, json: { error: { message: 'boom' } } }) }
return route.fallback()
})
await page.goto('/app/agents/new')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder-scope-one').click()
await page.getByTestId('agent-folder-choose').click()
const dialog = page.getByTestId('agent-folder-dialog')
await expect(dialog.getByRole('alert')).toContainText('Could not read this folder.')
await dialog.getByRole('button', { name: 'Try again' }).click()
await expect(dialog.getByRole('button', { name: /^Projects/ })).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).first().click()
await expect(dialog).toHaveCount(0)
})
test('the Export fold says that the file names the folder', async ({ page }) => {
const s = await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) })
await page.goto('/app/agents/a2/edit')
await fold(page, 'export').locator('.ag-fold__button').click()
await expect(page.getByTestId('agent-export-folder')).toContainText('The file names the folder /Projects/Acme.')
const download = page.waitForEvent('download')
await page.getByRole('button', { name: 'Export agent' }).click()
expect((await download).suggestedFilename()).toBe('code-reviewer.json')
expect(s.calls.some(c => c.url === '/api/chats/agents/a2/export')).toBe(true)
await expect(page.getByText(/The file names its folder, \/Projects\/Acme/)).toBeVisible()
})
test('no copy of the folder fold names an internal path', async ({ page }) => {
await installAgents(page, { files: HOME_FOLDERS, agents: withFolder(ACME_READ) })
await page.goto('/app/agents/a2/edit')
await fold(page, 'Files').locator('.ag-fold__button').click()
await page.getByTestId('agent-folder').getByRole('button', { name: 'Change', exact: true }).click()
await expect(page.getByTestId('folder-picker')).toBeVisible()
const text = await page.locator('body').innerText()
expect(text).not.toContain('/workspace/')
expect(text).not.toMatch(/\bnode\b/i)
})
})
const aliasModels = [
{ id: 'production', capabilities: ['FLAG_CHAT'] },
{ id: 'alternate', capabilities: ['FLAG_CHAT'] },
{ id: 'embedding', capabilities: ['FLAG_EMBEDDINGS'] },
{ id: 'disabled', capabilities: ['FLAG_CHAT'], disabled: true },
{ id: 'broken', capabilities: [] },
{ id: 'chained', capabilities: [] },
]
const openModel = async page => {
await fold(page, 'ModelSettings').locator('.ag-fold__button').click()
return page.getByTestId('home-model-chip')
}
test('create uses the chat picker, filters unusable models and persists the alias ID', async ({ page }) => {
const s = await installAgents(page, { models: aliasModels })
const aliasRequests = []
page.on('request', r => { if (new URL(r.url()).pathname.startsWith('/api/aliases')) aliasRequests.push(r.url()) })
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Alias user')
const chip = await openModel(page)
await expect(chip).toContainText('Any model')
await chip.click()
const options = page.getByRole('listbox').getByRole('option')
await expect(options.locator('code')).toHaveText(['Any model (the chat picks one)', 'production', 'alternate'])
await expect(options.locator('small')).toHaveText(['not loaded', 'not loaded'])
await options.filter({ has: page.getByText('production', { exact: true }) }).click()
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
const created = s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/agents')
expect(created.body.model).toBe('production')
const id = s.agents.find(a => a.name === 'Alias user').id
await page.goto(`/app/agents/${id}/edit`)
await expect(await openModel(page)).toContainText('production')
await page.reload()
await expect(await openModel(page)).toContainText('production')
expect(aliasRequests).toEqual([])
})
test('create can pick an alias, shows what it stands for, and saves the alias name', async ({ page }) => {
const models = [
{ id: 'production', capabilities: ['FLAG_CHAT'], backend: 'llama-cpp' },
{ id: 'daily', capabilities: ['FLAG_CHAT'], backend: 'llama-cpp', alias_of: 'production' },
{ id: 'lost', capabilities: [], alias_of: 'gone', alias_error: 'alias "lost" points to unknown model "gone"' },
]
const s = await installAgents(page, { models })
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Alias picker')
const chip = await openModel(page)
await chip.click()
const options = page.getByRole('listbox').getByRole('option')
await expect(options.filter({ hasText: 'daily' })).toContainText('alias of production')
await expect(options.filter({ hasText: 'lost' })).toHaveAttribute('aria-disabled', 'true')
if (process.env.PICKER_SHOTS) await page.waitForTimeout(600)
if (process.env.PICKER_SHOTS) await page.screenshot({ path: `${process.env.PICKER_SHOTS}/agent-create-picker.png` })
await options.filter({ hasText: 'daily' }).click()
await expect(chip).toContainText('daily')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/agents').body.model).toBe('daily')
})
test('edit retains the stored alias while capabilities load and through unrelated saves, then can change or clear it', async ({ page }) => {
const s = await installAgents(page, { models: aliasModels, agents: [{ ...AGENTS[0], model: 'production' }] })
let release
const loading = new Promise(resolve => { release = resolve })
await page.route('**/api/models/capabilities', async route => { await loading; await route.fulfill({ json: { data: aliasModels } }) })
await page.goto('/app/agents/a1/edit')
const chip = await openModel(page)
await expect(chip).toContainText('production')
release()
await page.getByLabel('Description').fill('Unrelated edit')
await page.getByRole('button', { name: 'Save Changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body.model).toBe('production')
await page.goto('/app/agents/a1/edit')
await (await openModel(page)).click()
await page.getByRole('listbox').getByRole('option').filter({ has: page.getByText('alternate', { exact: true }) }).click()
await page.getByRole('button', { name: 'Save Changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.agents[0].model).toBe('alternate')
await page.goto('/app/agents/a1/edit')
await expect(await openModel(page)).toContainText('alternate')
await page.getByTestId('home-model-chip').click()
await page.getByRole('option', { name: 'Any model (the chat picks one)', exact: true }).click()
await page.getByRole('button', { name: 'Save Changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.agents[0].model).toBe('')
await page.goto('/app/agents/a1/edit')
await expect(await openModel(page)).toContainText('Any model')
})
test('an unavailable stored alias is not silently replaced', async ({ page }) => {
const s = await installAgents(page, { models: aliasModels, agents: [{ ...AGENTS[0], model: 'unavailable-alias' }] })
await page.goto('/app/agents/a1/edit')
await expect(await openModel(page)).toContainText('unavailable-alias')
await page.getByLabel('Description').fill('Keep the model ID')
await page.getByRole('button', { name: 'Save Changes' }).first().click()
await expect(page).toHaveURL(/\/app\/agents$/)
expect(s.calls.find(c => c.method === 'PUT').body.model).toBe('unavailable-alias')
})