// 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 /** Create one session and its agent through the same wire face the browser uses. */ const createSession = async (sessionId: string): Promise => { 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 => { 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) })