Files
deepseek-harness/apps/web/tests/default-model.e2e.ts
T
Yichen Jiang 9679597204 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.
2026-08-07 13:49:47 +08:00

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)
})