Files
deepseek-harness/apps/web/tests/default-model.e2e.ts
T
Yichen Jiang 5a90eb41fb fix(ui-models): three faults the running app surfaced
**A hand-declared route must not offer a reasoning effort.** The earlier
commit read the create card's missing control as drift and added one. It
is the other way round: such a model has no reasoning capability — pi-ai's
installed catalog is what supplies one, and it ships nothing under the
route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every
model on it and the whole provider drops out of the picker. Verified
against the adapter, not inferred. The create card no longer offers it and
the editor withholds it on the directory's `declared` bit, which is the
real bug: that control has always been wrong for these routes.

**A blocked composer locked the way out of the block.** Reusing the
no-workspace inert posture disabled the model seat along with everything
else, so the bar asked for a model while preventing the one control that
picks one. A block now rides its own `blocked` owner prop: the textarea,
send, commands, plan seat, and access chip all lock, and the model seat
alone stays live.

**A Provider ID could derive an illegal credential reference.** The card
accepted a digit-leading id, whose derived `123_API_KEY` then failed at
the credential seam with a raw regular expression the user cannot act on.
The id must now start with a letter, and a test pins the relation between
the two rules rather than the regex.
2026-08-07 16:45:50 +08:00

168 lines
8.1 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. Both
// routes are declared host-side (not through the UI, which has its own
// scenario) through the pi-ai adapter the shipped tree already mounts: a
// fixture-less scaffold registers no adapter at all, so the routes the
// picker offers — and the one the composer must start on — have to come from
// somewhere, and settings profiles are the product's own way to add them.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
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'
/** Points the shipped `api-gateway` default at this scenario's own route. */
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
/** The route this scenario starts on, patched over the shipped default. */
const START_ROUTE = 'origin-gateway'
const START_MODEL = 'origin-large'
/** The route the switch lands on, which then becomes the saved default. */
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({ extraOverlayPath: OVERLAY })
// Two routes so the picker has somewhere to start and somewhere to go.
// 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: {
[START_ROUTE]: {
displayName: 'Origin Gateway',
api: 'openai-completions',
baseURL: 'https://gateway.origin.example/v1',
models: [{ id: START_MODEL, name: 'Origin Large' }],
},
[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: START_ROUTE, model: START_MODEL } },
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: START_ROUTE, model: START_MODEL })
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('goes inert when the route the default names stops being served', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-default-model-blocked'))
const box = page.locator('textarea[data-input-phase], textarea').first()
await expect.poll(async () => box.isEnabled(), { timeout: 10_000 }).toBe(true)
// What removing the provider on the Models page leaves behind: the saved
// default still names the route, and nothing serves it any more.
// `replace`, not `update`: a merge patch of `{providers: {}}` leaves every
// stored profile in place.
await scaffold.ctx.settings.replace(settingsNamespace('llm-pi-ai'), { providers: {} })
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(false)
expect(await box.getAttribute('placeholder')).toBe('当前模型不可用,请先选择模型')
// The block is an affordance; the refusal is the Host's. A client that
// never disabled anything still cannot start a turn on a dead route.
const refused = await scaffold.ctx.apiProxy.sessions.prompt({
rpcId: 'default-model-refused' as never,
payload: {
sessionId: SessionId(await createSession('default-model-refusal')),
mode: 'queue' as const,
content: [{ type: 'text' as const, text: 'hi' }],
},
})
expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
// The way out stays open. Locking the model seat with everything else
// would leave the composer asking for the one thing it prevents.
const seat = page.getByRole('button', { name: /^选择模型/ })
expect(await seat.isEnabled()).toBe(true)
await seat.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio').first().click()
await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})