test: update stale tests after the add-on owner and Sandbox skill changes (#13045)

* test(e2e): use a core built-in skill in the agent journeys

#13038 moved the docx, pdf, pptx and xlsx skills into the Sandbox
add-on, so core no longer embeds them. Three journeys still named docx
and xlsx as built-in skills. The backend e2e suite then failed: reading
/builtin/docx answered 404, and the system prompt had no docx entry.

The journeys now use localai-apps and localai-workspace, which core
still embeds. They keep their intent: an agent lists only the skills it
names, a plain chat lists all of them, and built-in skills are readable
but cannot be deleted.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): serve owner host settings in the add-on e2e fixtures

#13033 moved the network policy and the skill provider pins to the
add-on owner. The add-on Settings tab and the skills switch now read
and write them with the host-settings-read and host-settings-update
dispatch actions. The specs still mocked only /api/settings, so the
owner read failed and the network block never rendered. Six specs
failed on master.

mockAddons now answers both actions from a revisioned policy, checks
the expected revision, applies patches like hostsettings.Patch and logs
each patch. The specs assert the owner patch instead of the
/api/settings POST. They also follow two other changes of #13033: the
audit retention stays on the Settings page, and *.example.com is a
valid host pattern.

The document skill wording follows #13038: an add-on provides docx and
xlsx, it does not replace a built-in.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(routes): port the UI capability tests to Ginkgo

The capability tests from #13024 used t.Fatal and t.Errorf, which the
forbidigo lint rule rejects. The routes package already runs a Ginkgo
suite, so the two tests are now specs in it, with the same models and
the same assertions.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-11-02 22:09:45 +01:00
1 parent 8b4f3c35e8
commit 8f9a0b281d
6 files changed
+154 -145

No files matched your search

@@ -9,7 +9,7 @@ import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } f
// Studio and Talk are packaged add-ons, not built-in tiles: the specs install
// the real packages. No page is built in today, so `builtin` is empty unless a
// spec makes one up to check the built-in rules.
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases, features = { addon_navigation: true } } = {}) {
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases, hostSettings, features = { addon_navigation: true } } = {}) {
let saved = pins
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) }))
await page.route('**/api/features', route => route.fulfill({ json: features }))
@@ -18,7 +18,7 @@ async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true
// The packages are served from the build output, so a page that opens one runs it.
let mocked = null
if (studio) mocked = await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] })
else mocked = await mockAddons(page, { apps, installed, releases })
else mocked = await mockAddons(page, { apps, installed, releases, hostSettings })
await page.route('**/api/addons/pins', async route => {
if (route.request().method() === 'PUT') saved = route.request().postDataJSON()
await route.fulfill({ json: saved })
@@ -188,7 +188,7 @@ test.describe('Add-on navigation', () => {
test('an administrator gets a Settings tab in the details of an add-on with settings, with the host controls it has', async ({ page }) => {
const controlled = withSettings({ shadows: ['bash'], ui: { entry: `/api/addons/sandbox/assets/${'a'.repeat(64)}/ui.js`, sdk: { min: 2, max: 2 }, controls: ['network-policy'] } })
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [controlled, app({ id: 'notes', title: 'Notes' })], installed: [installedAddon({ id: 'sandbox', title: 'Sandbox' }), installedAddon()] })
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [controlled, app({ id: 'notes', title: 'Notes' })], installed: [installedAddon({ id: 'sandbox', title: 'Sandbox' }), installedAddon()], hostSettings: { chats_egress_ceiling: 'registries' } })
await page.route('**/api/settings', route => route.fulfill({ json: { chats_egress_ceiling: 'registries', chats_egress_audit_days: 14 } }))
await page.route('**/api/chats/admin/egress', route => route.fulfill({ json: { ceiling: 'registries', default: 'off', ports: [80, 443], audit_days: 14, registries: [], allowed_24h: 0, blocked_24h: 0 } }))
await page.goto('/app/addons?detail=sandbox')
@@ -200,7 +200,8 @@ test.describe('Add-on navigation', () => {
const block = pane.getByTestId('host-control-network-policy')
await expect(block).toBeVisible()
await expect(block.locator('[data-field="chats_egress_ceiling"] select')).toHaveValue('registries')
await expect(block.locator('[data-field="chats_egress_audit_days"] input')).toHaveValue('14')
// The audit retention stays on the Settings page: it is not owner policy.
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toHaveCount(0)
await expect(pane.getByTestId('addon-server-settings')).toHaveCount(0)
// Arrow keys move between the two tabs.
await pane.getByTestId('addon-detail-tab-settings').press('ArrowLeft')
+39 -1
View File
@@ -67,14 +67,44 @@ export async function stubAddonRoutes(page) {
await page.context().route('**/api/addons/pins', route => route.fulfill({ json: [] }))
}
// The owner's host settings policy (core/services/addons/hostsettings.Policy),
// in the exact shape host-settings-read answers. Keys of `over` that are not
// policy fields (other runtime settings) are ignored.
const HOST_POLICY_DEFAULTS = {
chats_egress_ceiling: '', chats_egress_default: '', chats_egress_allow_private: false,
chats_egress_ports: '', chats_egress_extra_registries: '', chats_egress_allowed_hosts: '',
chats_tool_providers: {}, auto_upgrade_addons: false,
}
export function hostPolicy(over = {}) {
const policy = structuredClone(HOST_POLICY_DEFAULTS)
for (const key of Object.keys(policy)) if (Object.hasOwn(over, key)) policy[key] = structuredClone(over[key])
return policy
}
// Applies a host-settings-update patch: present keys replace, a null provider
// value deletes that pin. Mirrors hostsettings.Patch.
function applyHostPatch(policy, patch) {
const next = structuredClone(policy)
for (const [key, value] of Object.entries(patch || {})) {
if (key !== 'chats_tool_providers') { next[key] = value; continue }
for (const [name, provider] of Object.entries(value || {})) {
if (provider === null) delete next.chats_tool_providers[name]
else next.chats_tool_providers[name] = provider
}
}
return next
}
// Installs the routes and returns the mutable state the spec reads and edits.
export async function mockAddons(page, options = {}) {
const state = {
// The owner's host settings; log.hostSettings collects each update patch.
hostSettings: { revision: 1, policy: hostPolicy(options.hostSettings) },
apps: options.apps || [app()],
installed: options.installed || [installedAddon()],
sources: options.sources || [{ id: 'main', name: 'Main', url: 'https://catalog.example/releases.json', enabled: true, policy: { requireSignature: false }, refresh: { checkedAt: new Date().toISOString(), lastSuccess: new Date().toISOString(), count: 2, failed: false } }],
releases: options.releases || [release('notes'), release('tables', { policy: { requireSignature: true, issuer: 'https://issuer.example', identity: 'release@catalog.example' } })],
log: { appsList: 0, start: [], stop: [], dispatch: [], check: [], upgrade: [], library: [] },
log: { appsList: 0, start: [], stop: [], dispatch: [], check: [], upgrade: [], library: [], hostSettings: [] },
// Overrides for the update routes: each takes the parsed body.
check: options.check || null,
upgrade: options.upgrade || null,
@@ -224,6 +254,14 @@ export async function mockAddons(page, options = {}) {
return route.fulfill(ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 0 }))
case 'operation-status':
return route.fulfill(ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: 'completed', Generation: 0 }))
case 'host-settings-read':
return route.fulfill(ok({ data: { schema_version: 1, revision: state.hostSettings.revision, policy: state.hostSettings.policy } }))
case 'host-settings-update': {
if (args.expectedRevision !== state.hostSettings.revision) return route.fulfill(ok({ error: 'revision_conflict' }))
state.log.hostSettings.push(args.patch)
state.hostSettings = { revision: state.hostSettings.revision + 1, policy: applyHostPatch(state.hostSettings.policy, args.patch) }
return route.fulfill(ok({ data: { schema_version: 1, revision: state.hostSettings.revision, policy: state.hostSettings.policy } }))
}
default:
return route.fulfill(fail(403, 'addon_error'))
}
+12 -7
View File
@@ -280,8 +280,10 @@ test.describe('A card the administrator\'s limit cannot admit', () => {
// The add-on pages of the network-policy control, with the settings and the
// admin network routes stubbed.
async function stubNetworkAdmin(page, { settings = {}, egress = {}, events = [], apps = RUNNERS } = {}) {
const state = await mockSettings(page, settings)
await mockAddons(page, { apps, installed: apps.map(a => installedAddon({ id: a.id, title: a.title })) })
await mockSettings(page, settings)
// The network policy is the owner's: the add-on page reads and writes it
// through host-settings-read and host-settings-update, not /api/settings.
const state = await mockAddons(page, { apps, installed: apps.map(a => installedAddon({ id: a.id, title: a.title })), hostSettings: settings })
await page.route('**/api/chats/admin/tools', route => route.fulfill({ json: { tools: [
{ id: 'bash', label: 'bash', description: 'Run commands', setting: 'chats_host_exec', mode: 'off', provider: 'sandbox', candidates: ['devbox', 'sandbox'], titles: { sandbox: 'Sandbox', devbox: 'Devbox' } },
{ id: 'web_search', label: 'web_search', description: 'Search the web', setting: 'chats_web_search', provider: 'builtin', candidates: [] },
@@ -341,7 +343,8 @@ test.describe('The network of the commands on the add-on page', () => {
await expect(page.getByTestId('settings-egress-serves')).toHaveText('Commands run isolated in the sandbox add-on')
await expect(page.getByTestId('settings-egress-counts')).toHaveText('Last 24 hours: 4 allowed, 1 blocked.')
await expect(block.locator('[data-field="chats_egress_ceiling"]')).toBeVisible()
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toBeVisible()
// The audit retention is a runtime setting of this host, on the Settings page.
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toHaveCount(0)
await page.getByTestId('settings-egress-registries').locator('summary').click()
await expect(block).toContainText('pypi.org, files.pythonhosted.org')
await page.getByTestId('settings-egress-audit-toggle').click()
@@ -356,7 +359,8 @@ test.describe('The network of the commands on the add-on page', () => {
await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled()
await page.getByTestId('settings-egress-private-phrase').fill('I understand that commands can reach my private network')
await page.getByRole('button', { name: 'Apply' }).click()
await expect.poll(() => settings.posts).toEqual([{ chats_egress_allow_private: true, chats_egress_private_confirm: 'I understand that commands can reach my private network' }])
// The phrase is checked in the page; the owner patch carries policy fields only.
await expect.poll(() => settings.log.hostSettings).toEqual([{ chats_egress_allow_private: true }])
await expect(page.getByTestId('settings-pending')).toHaveCount(0)
})
@@ -368,7 +372,7 @@ test.describe('The network of the commands on the add-on page', () => {
await expect(page.getByTestId('host-control-tool-providers')).toHaveCount(0)
await block.locator('[data-field="chats_egress_ceiling"] select').selectOption('registries')
await page.getByRole('button', { name: 'Apply' }).click()
await expect.poll(() => settings.posts).toEqual([{ chats_egress_ceiling: 'registries' }])
await expect.poll(() => settings.log.hostSettings).toEqual([{ chats_egress_ceiling: 'registries' }])
})
test('the Always allowed hosts list adds, refuses and removes hosts and saves them with Apply', async ({ page }) => {
@@ -382,7 +386,8 @@ test.describe('The network of the commands on the add-on page', () => {
await expect(editor.getByTestId('settings-hosts-count')).toHaveText('1/64')
const input = editor.getByTestId('settings-hosts-input')
await input.fill('*.example.com')
// *.example.com and * are accepted patterns; a star inside a name is not.
await input.fill('api.*.example.com')
await input.press('Enter')
await expect(editor.getByTestId('settings-hosts-error')).toContainText('not a pattern')
await input.fill('https://example.com')
@@ -408,6 +413,6 @@ test.describe('The network of the commands on the add-on page', () => {
await editor.getByRole('button', { name: 'Remove cdn.example.org' }).click()
await expect(editor.getByTestId('settings-hosts-item')).toHaveCount(3)
await page.getByRole('button', { name: 'Apply' }).click()
await expect.poll(() => settings.posts).toEqual([{ chats_egress_allowed_hosts: 'api.example.org, git.example.org:8443, xn--bcher-kva.example' }])
await expect.poll(() => settings.log.hostSettings).toEqual([{ chats_egress_allowed_hosts: 'api.example.org, git.example.org:8443, xn--bcher-kva.example' }])
})
})
+14 -8
View File
@@ -147,8 +147,10 @@ test.describe('Settings > Tools: skills that add-ons replace', () => {
})
})
test.describe('Install of an add-on that replaces a built-in skill', () => {
test('says which built-in skills it replaces, warns when another add-on already does, and still installs', async ({ page }) => {
// docx, pptx, pdf and xlsx have no core implementation: an add-on provides
// them, it does not replace a built-in.
test.describe('Install of an add-on that provides a document skill', () => {
test('says which document skills it provides, warns when another add-on already does, and still installs', async ({ page }) => {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
await mockAddons(page, {
installed: [installedAddon({ id: 'sheets', title: 'Sheets', skillShadows: ['xlsx'] })],
@@ -158,8 +160,8 @@ test.describe('Install of an add-on that replaces a built-in skill', () => {
await page.getByRole('button', { name: 'Install Office' }).click()
const warnings = page.getByTestId('install-skill-warning')
await expect(warnings).toHaveCount(2)
await expect(warnings.nth(0)).toHaveText('This add-on replaces the built-in skill xlsx. Another add-on (Sheets) already does. Only one can be active; you choose which in Settings > Tools. Until you choose, the one installed first serves.')
await expect(warnings.nth(1)).toHaveText('This add-on replaces the built-in skill docx.')
await expect(warnings.nth(0)).toHaveText('This add-on provides the xlsx skill. Another add-on (Sheets) also provides it. Only one can be active; choose in Settings > Tools. Without a choice, the one installed first serves.')
await expect(warnings.nth(1)).toHaveText('This add-on provides the docx skill.')
await expect(page.getByTestId('install-dialog').getByRole('button', { name: 'Install and turn on' })).toBeEnabled()
await shot(page, 'install-skill-warning')
})
@@ -169,11 +171,13 @@ test.describe('The skills of an add-on in its details', () => {
test('lists its skills, and lets an administrator choose it for a skill another add-on also replaces', async ({ page }) => {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) }))
const office = { id: 'office', name: 'office', title: 'Office', skills: [{ name: 'xlsx', shadows: 'xlsx' }, { name: 'docx', shadows: 'docx' }, { name: 'office-tips' }] }
await mockAddons(page, {
const owner = await mockAddons(page, {
apps: [{ ...installedAddon({ id: 'office', title: 'Office' }), ...office, admin: { canStart: false, canStop: true, managementUrl: '/app/addons?manage=office' } }],
installed: [installedAddon({ id: 'office', title: 'Office', skillShadows: ['xlsx', 'docx'] })],
// Sheets is the pinned provider of xlsx: the owner's pins say who serves.
hostSettings: { chats_tool_providers: { bash: 'sandbox', xlsx: 'sheets' } },
})
const settings = await mockSettings(page, { chats_tool_providers: { bash: 'sandbox' } })
await mockSettings(page)
let rows = [
{ id: 'xlsx', provider: 'sheets', candidates: ['office', 'sheets'], titles: { office: 'Office', sheets: 'Sheets' } },
{ id: 'docx', provider: 'office', candidates: ['office'], titles: { office: 'Office' } },
@@ -183,14 +187,16 @@ test.describe('The skills of an add-on in its details', () => {
const skills = page.getByTestId('app-skills')
await expect(skills).toContainText('3 chat skills')
await expect(page.getByTestId('app-skill-office-tips')).not.toContainText('replaces')
await expect(page.getByTestId('app-skill-docx')).toContainText('replaces the built-in skill · serves in the chats')
await expect(page.getByTestId('app-skill-docx')).toContainText('provides the docx skill · serves in the chats')
await expect(page.getByTestId('app-skill-docx-switch')).toHaveCount(0)
const sw = page.getByTestId('app-skill-xlsx-switch')
await expect(sw).not.toBeChecked()
await shot(page, 'addon-skills')
rows = [{ ...rows[0], provider: 'office', pinned: 'office' }, rows[1]]
await sw.click()
await expect.poll(() => settings.posts).toEqual([{ chats_tool_providers: { bash: 'sandbox', xlsx: 'office' } }])
// The owner gets only the changed pin; the bash pin stays as it was.
await expect.poll(() => owner.log.hostSettings).toEqual([{ chats_tool_providers: { xlsx: 'office' } }])
expect(owner.hostSettings.policy.chats_tool_providers).toEqual({ bash: 'sandbox', xlsx: 'office' })
await expect(sw).toBeChecked()
})
})
+76 -117
View File
@@ -6,8 +6,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"slices"
"testing"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
@@ -16,91 +14,33 @@ import (
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestUIUpscaleCapabilities(t *testing.T) {
dir := t.TempDir()
st, err := system.GetSystemState(system.WithModelPath(dir))
if err != nil {
t.Fatal(err)
}
cl := config.NewModelConfigLoader(dir)
for name, options := range map[string]string{
"upscaler": "options: [upscale_scale:4]\n",
"absent": "",
"malformed": "options: [upscale_scale:bad]\n",
"nonpositive": "options: [upscale_scale:0]\n",
"negative": "options: [upscale_scale:-1]\n",
"overflow": "options: [upscale_scale:2147483648]\n",
"duplicate": "options: [upscale_scale:4, upscale_scale:4]\n",
} {
path := filepath.Join(dir, name+".yaml")
if err := os.WriteFile(path, []byte("name: "+name+"\nbackend: stablediffusion-ggml\nknown_usecases: [upscale]\n"+options), 0600); err != nil {
t.Fatal(err)
}
if err := cl.ReadModelConfig(path); err != nil {
t.Fatal(err)
}
}
ac := config.NewApplicationConfig()
ac.SystemState = st
ml := model.NewModelLoader(st)
gs := galleryop.NewGalleryService(ac, ml)
app := echo.New()
routes.RegisterUIAPIRoutes(app, cl, ml, ac, gs, galleryop.NewOpCache(gs), &application.Application{}, func(next echo.HandlerFunc) echo.HandlerFunc { return next })
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/models/capabilities", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var response struct {
Data []struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
Scale *int `json:"upscaleScale"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Data) != 7 {
t.Fatalf("unexpected model count: %d", len(response.Data))
}
for _, entry := range response.Data {
if len(entry.Capabilities) != 1 || entry.Capabilities[0] != "FLAG_UPSCALE" {
t.Fatalf("unexpected capabilities: %+v", entry)
}
if entry.ID == "upscaler" {
if entry.Scale == nil || *entry.Scale != 4 {
t.Fatalf("unexpected scale: %+v", entry)
}
} else if entry.Scale != nil {
t.Fatalf("invalid scale exposed: %+v", entry)
}
}
type capabilityEntry struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
Scale *int `json:"upscaleScale"`
Backend string `json:"backend"`
Disabled bool `json:"disabled"`
Pinned bool `json:"pinned"`
MCP bool `json:"mcp"`
}
func TestUIAliasCapabilities(t *testing.T) {
dir := t.TempDir()
// capabilitiesOf writes one model config per entry (name -> YAML after the
// name line), registers the UI API routes and returns the answer of
// GET /api/models/capabilities.
func capabilitiesOf(models map[string]string) []capabilityEntry {
dir := GinkgoT().TempDir()
st, err := system.GetSystemState(system.WithModelPath(dir))
if err != nil {
t.Fatal(err)
}
Expect(err).ToNot(HaveOccurred())
cl := config.NewModelConfigLoader(dir)
for name, body := range map[string]string{
"target": "backend: llama-cpp\nknown_usecases: [chat, vision]\npinned: true\n",
"alias": "alias: target\nknown_usecases: [embeddings]\nprofile:\n system_prompt: Answer briefly.\n client_tools: drop\n",
"disabled-alias": "alias: target\ndisabled: true\n",
"broken": "alias: missing\nknown_usecases: [chat]\n",
"chain": "alias: alias\nknown_usecases: [chat]\n",
} {
for name, body := range models {
path := filepath.Join(dir, name+".yaml")
if err := os.WriteFile(path, []byte("name: "+name+"\n"+body), 0600); err != nil {
t.Fatal(err)
}
if err := cl.ReadModelConfig(path); err != nil {
t.Fatal(err)
}
Expect(os.WriteFile(path, []byte("name: "+name+"\n"+body), 0600)).To(Succeed())
Expect(cl.ReadModelConfig(path)).To(Succeed())
}
ac := config.NewApplicationConfig()
ac.SystemState = st
@@ -110,43 +50,62 @@ func TestUIAliasCapabilities(t *testing.T) {
routes.RegisterUIAPIRoutes(app, cl, ml, ac, gs, galleryop.NewOpCache(gs), &application.Application{}, func(next echo.HandlerFunc) echo.HandlerFunc { return next })
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/models/capabilities", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
var response struct {
Data []struct {
ID string `json:"id"`
Capabilities []string `json:"capabilities"`
Backend string `json:"backend"`
Disabled bool `json:"disabled"`
Pinned bool `json:"pinned"`
MCP bool `json:"mcp"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Data) != 5 {
t.Fatalf("unexpected models: %+v", response.Data)
}
for _, entry := range response.Data {
switch entry.ID {
case "target", "alias", "disabled-alias":
if len(entry.Capabilities) != 2 || !slices.Contains(entry.Capabilities, "FLAG_CHAT") || !slices.Contains(entry.Capabilities, "FLAG_VISION") {
t.Errorf("%s must inherit chat/vision: %+v", entry.ID, entry)
}
case "broken", "chain":
if len(entry.Capabilities) != 0 {
t.Errorf("invalid alias gained capabilities: %+v", entry)
}
default:
t.Errorf("unexpected ID: %s", entry.ID)
}
if entry.ID != "target" && (entry.Backend != "" || entry.Pinned || entry.MCP) {
t.Errorf("alias inherited management metadata: %+v", entry)
}
if entry.Disabled != (entry.ID == "disabled-alias") {
t.Errorf("disabled changed: %+v", entry)
}
Data []capabilityEntry `json:"data"`
}
Expect(json.Unmarshal(rec.Body.Bytes(), &response)).To(Succeed())
return response.Data
}
var _ = Describe("UI model capabilities", func() {
It("exposes only a valid upscale scale", func() {
const upscale = "backend: stablediffusion-ggml\nknown_usecases: [upscale]\n"
data := capabilitiesOf(map[string]string{
"upscaler": upscale + "options: [upscale_scale:4]\n",
"absent": upscale,
"malformed": upscale + "options: [upscale_scale:bad]\n",
"nonpositive": upscale + "options: [upscale_scale:0]\n",
"negative": upscale + "options: [upscale_scale:-1]\n",
"overflow": upscale + "options: [upscale_scale:2147483648]\n",
"duplicate": upscale + "options: [upscale_scale:4, upscale_scale:4]\n",
})
Expect(data).To(HaveLen(7))
for _, entry := range data {
Expect(entry.Capabilities).To(Equal([]string{"FLAG_UPSCALE"}), entry.ID)
if entry.ID == "upscaler" {
Expect(entry.Scale).ToNot(BeNil())
Expect(*entry.Scale).To(Equal(4))
} else {
Expect(entry.Scale).To(BeNil(), "invalid scale exposed for %s", entry.ID)
}
}
})
It("gives a valid alias the capabilities of its target and nothing else", func() {
data := capabilitiesOf(map[string]string{
"target": "backend: llama-cpp\nknown_usecases: [chat, vision]\npinned: true\n",
"alias": "alias: target\nknown_usecases: [embeddings]\nprofile:\n system_prompt: Answer briefly.\n client_tools: drop\n",
"disabled-alias": "alias: target\ndisabled: true\n",
"broken": "alias: missing\nknown_usecases: [chat]\n",
"chain": "alias: alias\nknown_usecases: [chat]\n",
})
Expect(data).To(HaveLen(5))
for _, entry := range data {
switch entry.ID {
case "target", "alias", "disabled-alias":
Expect(entry.Capabilities).To(ConsistOf("FLAG_CHAT", "FLAG_VISION"), "%s must inherit chat/vision", entry.ID)
case "broken", "chain":
Expect(entry.Capabilities).To(BeEmpty(), "invalid alias %s gained capabilities", entry.ID)
default:
Fail("unexpected ID: " + entry.ID)
}
if entry.ID != "target" {
Expect(entry.Backend).To(BeEmpty(), "alias %s inherited management metadata", entry.ID)
Expect(entry.Pinned).To(BeFalse(), entry.ID)
Expect(entry.MCP).To(BeFalse(), entry.ID)
}
Expect(entry.Disabled).To(Equal(entry.ID == "disabled-alias"), entry.ID)
}
})
})
+8 -8
View File
@@ -101,7 +101,7 @@ func agGap(open bool, id, what string) {
var _ = Describe("Chats saved agents", Label("Chats"), func() {
It("validates, replaces and deletes an agent, and refuses a chat with a deleted one", func() {
name := fmt.Sprintf("Agent e2e %d", time.Now().UnixNano())
id := agCreateAgent(agObj{"name": name, "instructions": "Be brief.", "model": "mock-model", "skills": []string{"docx"}})
id := agCreateAgent(agObj{"name": name, "instructions": "Be brief.", "model": "mock-model", "skills": []string{"localai-apps"}})
code, _ := chatsDo(http.MethodPost, chatsBase()+"/agents", agObj{"name": strings.ToUpper(name)})
Expect(code).To(Equal(http.StatusConflict), "names are unique ignoring case")
@@ -157,15 +157,15 @@ var _ = Describe("Chats saved agents", Label("Chats"), func() {
It("sends the agent's instructions and only its skills to the model, and a plain chat gets all of them", func() {
agNeedUpstream()
id := agCreateAgent(agObj{"name": fmt.Sprintf("Prompt e2e %d", time.Now().UnixNano()), "model": "cp-passthrough-openai",
"instructions": "AGENT-INSTRUCTION-MARK", "skills": []string{"docx"}})
"instructions": "AGENT-INSTRUCTION-MARK", "skills": []string{"localai-apps"}})
system, _ := agUpstreamRequest(agObj{"agent_id": id}, "hello")
Expect(system).To(ContainSubstring("AGENT-INSTRUCTION-MARK"))
Expect(system).To(ContainSubstring("- docx:"))
Expect(system).NotTo(ContainSubstring("- xlsx:"), "an agent lists only the skills it names")
Expect(system).To(ContainSubstring("- localai-apps:"))
Expect(system).NotTo(ContainSubstring("- localai-workspace:"), "an agent lists only the skills it names")
plain, _ := agUpstreamRequest(agObj{"model": "cp-passthrough-openai"}, "hello")
Expect(plain).To(ContainSubstring("- docx:"))
Expect(plain).To(ContainSubstring("- xlsx:"))
Expect(plain).To(ContainSubstring("- localai-apps:"))
Expect(plain).To(ContainSubstring("- localai-workspace:"))
Expect(plain).NotTo(ContainSubstring("AGENT-INSTRUCTION-MARK"))
})
@@ -283,9 +283,9 @@ var _ = Describe("Chats skill layers", Label("Chats"), func() {
code, _ := chatsDo(http.MethodPost, skillsURL(""), agObj{"layer": layer, "name": "x-layer", "description": "d", "body": "b"})
Expect(code).To(Equal(http.StatusBadRequest), layer)
}
code, _ := chatsDo(http.MethodGet, skillsURL("/builtin/docx"), nil)
code, _ := chatsDo(http.MethodGet, skillsURL("/builtin/localai-apps"), nil)
Expect(code).To(Equal(http.StatusOK), "built-in skills are readable")
code, _ = chatsDo(http.MethodDelete, skillsURL("/builtin/docx"), nil)
code, _ = chatsDo(http.MethodDelete, skillsURL("/builtin/localai-apps"), nil)
Expect(code).NotTo(Equal(http.StatusOK), "built-in skills cannot be deleted")
})