Files
LocalAI/core/http/react-ui/e2e/chats-connection.spec.js
T
Ettore Di Giacinto ad39954af1 feat(addons): let a chat enable several connections of a credentialed add-on
A chat now enables a set of connections for an add-on whose tools run with
a credential, and the model names one of them in each call. This applies to
every add-on that declares a credential in its manifest.

- The Tools menu row of the add-on lists the connections with a checkbox
  each. The saved agent names a set too (an empty set means each chat
  chooses). A chat that a connector binding started keeps that binding
  alone. With one connection, it is the set. With none enabled, the tools
  are not offered.
- With one connection enabled the tools are unchanged. With two or more,
  the host adds a required `connection` argument to every tool of the
  add-on in the definitions it gives the model: an enum of the connection
  names (the display name folded to lower case, digits and hyphens; the
  description keeps the display name). The add-on never sees the argument.
- The name is checked against the set the chat has now, not against all the
  connections of the person. A missing or unknown name returns a sentence
  that lists the allowed names, and nothing is sent. A write tool never runs
  without the name. Text in a tool result cannot widen the set, and the
  owner checks the chosen binding against the set again.
- The resource list of the named connection is the only one that applies.
  Results, the approval card and the text for a pending change name the
  connection; the audit line has the binding ID.
- A connection name must be unique per person and add-on among the
  credential-only connections, compared by name fold. Older duplicates get
  a numeric suffix in the model's list.
- The stored chat, draft and agent values move from one binding ID per
  add-on to a list of IDs. A single string is read as a set of one in the
  stores and in every request, so older data and clients keep working.
  PUT /api/chats/{id}/connections takes `connections`; the older
  `connection` field still works.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo node eslint playwright swag
2026-11-03 22:33:28 +00:00

159 lines
9.1 KiB
JavaScript

import { test, expect } from './coverage-fixtures.js'
import { mockChat } from './chat-fixtures.js'
import { installAgents, DRAFT_TOOLS } from './agents-fixtures.js'
// The connections a chat may use for an add-on that runs with a credential.
// The host never picks them for the person: with one connection it is the set,
// with several the person ticks the ones this chat may use in the Tools menu,
// with none the row says so, and an agent that names a set decides it. The
// add-ons here are stand-ins: nothing depends on which product they are. The
// API is stubbed.
const SHOTS = process.env.CONNECTION_SHOT_DIR
// A screenshot for review waits for the menu's opening motion to end.
const shot = async (page, name) => { if (SHOTS) { await page.waitForTimeout(400); await page.screenshot({ path: `${SHOTS}/${name}.png` }) } }
const WORK = { id: 'b1', name: 'Work', handle: 'work' }
const HOME = { id: 'b2', name: 'Home', handle: 'home' }
const PLAY = { id: 'b3', name: 'Play', handle: 'play' }
async function stubChat(page, makeRow) {
const detail = {
id: 'c1', title: 'Issues', model: 'qwen3-8b', kind: 'chat', created: '2026-10-25T10:00:00Z', updated: '2026-10-25T10:00:00Z',
running: false, last_turn: 'done', pending: [],
}
await page.route('**/api/chats/c1/events', route => route.abort())
await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, route => route.fulfill({
json: { messages: [{ seq: 1, turn: 1, role: 'user', text: 'list the open issues', at: '2026-10-25T10:00:00Z' }] },
}))
await page.route('**/api/chats/c1', route => route.fulfill({ json: detail }))
await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({
json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true, files_reason: '' },
}))
const state = { picked: [], row: makeRow(null) }
await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: { addons: [state.row], commands: 'none' } }))
await page.route('**/api/chats/c1/connections', route => {
const body = route.request().postDataJSON()
state.picked.push(body)
state.row = makeRow(body.connections)
return route.fulfill({ json: { addons: [state.row], commands: 'none' } })
})
return state
}
const row = (extra) => ({ id: 'tracker', tools: 14, off: false, title: 'Tracker', description: 'Read and change items.', credentialed: true, ...extra })
const openTools = async (page) => {
await page.goto('/app/chats/c1')
await page.getByTestId('chat-tools-button').click()
}
test.describe('The connections of an add-on in the Tools menu', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
test('several connections: none is enabled for the person, the tools wait, and ticking switches the row on', async ({ page }) => {
const state = await stubChat(page, (picked) => row({
connections: [WORK, HOME, PLAY], enabled_connections: picked || [], connection_by: picked?.length ? 'chat' : '', needs_connection: !picked?.length,
}))
await openTools(page)
const item = page.getByTestId('chat-tools-item')
await expect(item).toContainText('Choose at least one connection to use these tools')
await expect(item).toHaveAttribute('data-on', 'false')
const checks = page.getByTestId('chat-tools-pick-check')
await expect(checks).toHaveCount(3)
for (const c of await checks.all()) await expect(c).not.toBeChecked()
await expect(page.getByTestId('chat-tools-pick-hint')).toContainText('Choose the connections this chat may use')
await expect(page.getByRole('group', { name: 'Connections' })).toBeVisible()
await shot(page, 'choose')
await page.getByLabel('Home').check()
await expect.poll(() => state.picked).toEqual([{ addon: 'tracker', connections: ['b2'] }])
await expect(item).toHaveAttribute('data-on', 'true')
await expect(item).not.toContainText('Choose at least one connection')
await expect(page.getByTestId('chat-tools-pick-hint')).toContainText('Choose the connections this chat may use')
await page.getByLabel('Work').check()
await expect.poll(() => state.picked.at(-1)).toEqual({ addon: 'tracker', connections: ['b1', 'b2'] })
await expect(page.getByTestId('chat-tools-pick-hint')).toContainText('names the connection in each action')
await shot(page, 'chosen')
await page.getByLabel('Home').uncheck()
await expect.poll(() => state.picked.at(-1)).toEqual({ addon: 'tracker', connections: ['b1'] })
})
test('one connection is enabled already and cannot be unticked', async ({ page }) => {
await stubChat(page, () => row({ connections: [WORK], enabled_connections: ['b1'], connection_by: 'only', needs_connection: false }))
await openTools(page)
await expect(page.getByTestId('chat-tools-item')).toHaveAttribute('data-on', 'true')
const check = page.getByTestId('chat-tools-pick-check')
await expect(check).toBeChecked()
await expect(check).toBeDisabled()
await expect(page.getByTestId('chat-tools-pick-hint')).toContainText('your only connection')
})
test('no connection yet: the row says so and links to the Connectors page', async ({ page }) => {
await stubChat(page, () => row({ connections: [], needs_connection: true }))
await openTools(page)
await expect(page.getByTestId('chat-tools-item')).toContainText('No connection yet')
await expect(page.getByTestId('chat-tools-pick-check')).toHaveCount(0)
await expect(page.getByTestId('chat-tools-pick-link')).toHaveAttribute('href', '/app/chats/connectors')
})
test('the set an agent names is shown and cannot be changed here', async ({ page }) => {
await stubChat(page, () => row({ connections: [WORK, HOME, PLAY], enabled_connections: ['b1', 'b2'], connection_by: 'agent', needs_connection: false }))
await openTools(page)
const checks = page.getByTestId('chat-tools-pick-check')
await expect(checks.nth(0)).toBeChecked()
await expect(checks.nth(1)).toBeChecked()
await expect(checks.nth(2)).not.toBeChecked()
for (const c of await checks.all()) await expect(c).toBeDisabled()
await expect(page.getByTestId('chat-tools-pick-hint')).toContainText('The agent of this chat sets the connections')
})
test('two add-ons with a credential keep a set each', async ({ page }) => {
await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: { addons: [
row({ connections: [WORK, HOME], enabled_connections: ['b1', 'b2'], connection_by: 'chat' }),
row({ id: 'ledger', title: 'Ledger', connections: [{ id: 'l1', name: 'Books', handle: 'books' }], enabled_connections: ['l1'], connection_by: 'only' }),
], commands: 'none' } }))
await page.route('**/api/chats/c1', route => route.fulfill({ json: { id: 'c1', title: 'Issues', model: 'qwen3-8b', kind: 'chat', created: '2026-10-25T10:00:00Z', updated: '2026-10-25T10:00:00Z', running: false, last_turn: 'done', pending: [] } }))
await page.route('**/api/chats/c1/events', route => route.abort())
await openTools(page)
await expect(page.getByTestId('chat-tools-pick')).toHaveCount(2)
await expect(page.getByTestId('chat-tools-pick-check')).toHaveCount(3)
await expect(page.getByLabel('Books')).toBeDisabled()
await expect(page.getByLabel('Work')).toBeEnabled()
})
test('an add-on with no credential has no connection control', async ({ page }) => {
await stubChat(page, () => ({ id: 'office', tools: 3, off: false, title: 'Office' }))
await openTools(page)
await expect(page.getByTestId('chat-tools-item')).toBeVisible()
await expect(page.getByTestId('chat-tools-pick')).toHaveCount(0)
})
})
test.describe('The connections an agent names', () => {
test('the agent form names a set per add-on, and shows that none means each chat chooses', async ({ page }) => {
const s = await installAgents(page)
// Later routes win: the draft list now has an add-on with a credential.
await page.route('**/api/chats/draft/tools*', route => route.fulfill({
json: {
...DRAFT_TOOLS, servers: [], off: [], connectors: [],
addons: [{ id: 'tracker', tools: 14, off: false, title: 'Tracker', credentialed: true, connections: [WORK, HOME, PLAY], enabled_connections: [], needs_connection: true }],
},
}))
await page.goto('/app/agents/new')
await page.getByLabel('Name', { exact: false }).first().fill('Reviewer')
await page.locator('[data-section="Tools"] .ag-fold__button').click()
const panel = page.getByTestId('agent-tools-panel')
const group = panel.getByRole('group', { name: 'Connections for chats of this agent' })
await expect(group).toBeVisible()
await expect(panel.getByTestId('agent-tools-pick-hint')).toContainText('each chat chooses its own')
await group.getByLabel('Home').check()
await group.getByLabel('Play').check()
await expect(panel.getByTestId('agent-tools-pick-hint')).toContainText('cannot change them')
await panel.getByRole('switch', { name: /Tracker/ }).click()
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.connections).toEqual({ tracker: ['b2', 'b3'] })
expect(body.addons).toEqual(['tracker'])
})
})