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.
116 lines
5.5 KiB
TypeScript
116 lines
5.5 KiB
TypeScript
// 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)
|
|
})
|