feat(ui-models): tag the provider rows this deployment declared
A row's stored profile could not tell a hand-declared gateway from a shipped provider whose models someone narrowed — both look identical from outside the adapter — so the Models page had no way to mark the routes a deployment added itself. The directory entry now carries `declared`, answered by the owning adapter against its own installed catalog, and the page renders a Custom tag from it. Absence stays "this adapter draws no such distinction" rather than "shipped", so a route no adapter claims is labelled neither way. Also records the default-route work's Agent Note and the e2e evidence for all three changes: the composer switch writing the section, and the Models page declaring a route with its own reasoning effort.
This commit is contained in:
115
apps/web/tests/default-model.e2e.ts
Normal file
115
apps/web/tests/default-model.e2e.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Web e2e scenario: switching models in the composer is how this deployment's
|
||||
// default is chosen. The gesture writes the `api-gateway` settings section, a
|
||||
// session created afterwards starts from it, and a session that already logged
|
||||
// a route keeps deriving from its own log — the tier order the gateway
|
||||
// resolves on every read.
|
||||
// Zero model calls: the switch is settings/llm-domain traffic only, so there
|
||||
// is no fixture and a stray stream would fail loud on the open seam. A second
|
||||
// route is declared host-side (not through the UI, which has its own
|
||||
// scenario) purely so the picker has somewhere to switch to: the keyless
|
||||
// replay catalog publishes a single model.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** The route declared for this scenario, and the model the switch lands on. */
|
||||
const ROUTE = 'acme-gateway'
|
||||
const MODEL = 'acme-large'
|
||||
|
||||
describe('web e2e: the composer model switch is the default for later sessions', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
/** Create one session and its agent through the same wire face the browser uses. */
|
||||
const createSession = async (sessionId: string): Promise<string> => {
|
||||
const response = await scaffold.ctx.apiProxy.sessions.create({
|
||||
rpcId: `default-model-create-${sessionId}` as never,
|
||||
payload: { sessionId: SessionId(sessionId), cwd: scaffold.workspaceCwd },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(`session.create failed: ${response.result.error.message}`)
|
||||
return response.result.value.sessionId
|
||||
}
|
||||
|
||||
/** The route the gateway reports for one session, through the real wire face. */
|
||||
const currentOf = async (sessionId: string): Promise<unknown> => {
|
||||
const response = await scaffold.ctx.apiProxy.sessions.models({
|
||||
rpcId: `default-model-${sessionId}` as never,
|
||||
payload: { sessionId: SessionId(sessionId) },
|
||||
})
|
||||
if (!response.result.ok) throw new Error(`session.models failed: ${response.result.error.message}`)
|
||||
return response.result.value.current
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// A second route so the picker has two models. Declared through the
|
||||
// settings seam rather than the Models page: this scenario is about the
|
||||
// composer, and the declaring flow is covered by models-settings.e2e.
|
||||
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
[ROUTE]: {
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: MODEL, name: 'Acme Large' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The composer's seats only exist once a workspace is connected: without
|
||||
// one the input is the locked placeholder and no session scope is open.
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('writes the switched model as the default and leaves a logged session alone', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model'))
|
||||
// A session that has already run a turn, spelled as the fact a turn
|
||||
// leaves behind: its own logged route.
|
||||
const loggedId = await createSession('default-model-logged')
|
||||
scaffold.ctx.sessions.get(SessionId(loggedId))?.append('request/header', {
|
||||
header: { config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
|
||||
|
||||
// The switch is what sets the default: the gateway's own settings section
|
||||
// now names it, beside the provider profiles the Models page writes.
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('api-gateway:')
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain(`provider: ${ROUTE}`)
|
||||
expect(document).toContain(`model: ${MODEL}`)
|
||||
|
||||
// A session created after the switch starts from it...
|
||||
expect(await currentOf(await createSession('default-model-after')))
|
||||
.toEqual({ provider: ROUTE, model: MODEL })
|
||||
// ...while the one holding a logged route keeps deriving from its log.
|
||||
expect(await currentOf(loggedId))
|
||||
.toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
@@ -114,10 +115,47 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('declares a route the adapter does not ship, with its own reasoning effort', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
|
||||
await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await declare.click()
|
||||
await dialog.getByLabel('Provider ID').fill('acme-gateway')
|
||||
await dialog.getByLabel('显示名称').fill('Acme Gateway')
|
||||
await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
|
||||
// The create card offers the same provider-level effort the editor card
|
||||
// does for this namespace; a route declared without it would gain the
|
||||
// control only on reopening.
|
||||
await dialog.getByLabel('推理强度').selectOption('high')
|
||||
await dialog.getByRole('button', { name: '添加模型' }).click()
|
||||
await dialog.getByLabel('模型 ID 1').fill('acme-large')
|
||||
await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
|
||||
|
||||
const row = dialog.getByText('Acme Gateway', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('acme-gateway:')
|
||||
expect(document).toContain('reasoning: high')
|
||||
|
||||
// The tag follows the adapter's installed catalog: this route is in no
|
||||
// catalog, while minimax-cn is — even though both now have profiles.
|
||||
const rowCard = (name: string) => dialog.locator('li').filter({ hasText: name }).first()
|
||||
await expect.poll(async () => rowCard('Acme Gateway').getByText('自定义').count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await rowCard('minimax-cn').getByText('自定义').count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DECLARED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms provider deletion before removing its settings profile', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
// Two rows carry a delete action now that a route is also declared; this
|
||||
// scenario is about minimax-cn, so it names its own row.
|
||||
const minimaxRow = settingsDialog.locator('li').filter({ hasText: 'minimax-cn' }).first()
|
||||
await minimaxRow.getByRole('button', { name: '删除', exact: true }).click()
|
||||
const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
await deleteDialog.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(
|
||||
@@ -129,7 +167,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await minimaxRow.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
.getByRole('button', { name: '删除提供方', exact: true }).click()
|
||||
await expect.poll(
|
||||
@@ -147,6 +185,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR,
|
||||
['configured.expected.md', 'declared.expected.md', 'delete.expected.md', 'empty.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
- button "编辑"
|
||||
- button "删除"
|
||||
- listitem:
|
||||
- text: Acme Gateway 自定义
|
||||
- button "编辑"
|
||||
- button "删除"
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
@@ -37,6 +37,7 @@
|
||||
"tests/details-session-lifecycle.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/default-model.e2e.ts",
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/remote-welcome.e2e.ts",
|
||||
"tests/workspace-management.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user