feat(ui): make a session that cannot send refuse to accept one
A default naming a route the Models page has since removed left the composer saying 选择模型 while the input still accepted a message, which then failed inside the adapter mid-turn. `session.prompt` now refuses with `model-unavailable` before opening a turn. That is the enforcement boundary: the method stays callable no matter what a client disables. `session.models` reports the same fact as `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry so the bar renders the disabled textarea it already renders without a workspace, carrying the blocker's own reason. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back. The gate is `routable`, not "matches no advertised group": catalog membership is advisory, so a route serving a model it stopped advertising is missing from the groups yet perfectly usable, and `null` before the first load never blocks so a slow Host cannot lock a working composer. The scaffold gains a route-only adapter for fixture-less keyless scenarios. Registering zero providers is a test artifact — every product composition mounts one — and the goldens that froze the seat's fallback label now show the model those scenarios actually route to.
This commit is contained in:
@@ -4,11 +4,14 @@
|
||||
// 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.
|
||||
// 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'
|
||||
@@ -18,7 +21,13 @@ 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. */
|
||||
/** 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'
|
||||
|
||||
@@ -49,12 +58,19 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
}
|
||||
|
||||
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.
|
||||
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',
|
||||
@@ -84,7 +100,7 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
// 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' } },
|
||||
header: { config: { provider: START_ROUTE, model: START_MODEL } },
|
||||
reason: 'initial',
|
||||
})
|
||||
|
||||
@@ -108,8 +124,35 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
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(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' } })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
8
apps/web/tests/default-model.overlay.yml
Normal file
8
apps/web/tests/default-model.overlay.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
# The fixture-less web scaffold registers no adapter, so the shipped
|
||||
# deepseek-official default would be a route nothing serves — which the
|
||||
# composer now correctly refuses to type into. This scenario declares its own
|
||||
# pi-ai routes and starts the default on one of them.
|
||||
- id: api-gateway
|
||||
config:
|
||||
provider: origin-gateway
|
||||
model: origin-large
|
||||
@@ -126,7 +126,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
|
||||
@@ -45,6 +45,10 @@ import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -93,6 +97,46 @@ const REPLAY_PROVIDERS = [{
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
|
||||
}]
|
||||
|
||||
/**
|
||||
* The routes a shipped composition always has, with no ability to stream.
|
||||
* A fixture-less keyless scenario issues no model calls, but its tree must
|
||||
* still answer `listProviders()` — surfaces legitimately gate on whether any
|
||||
* adapter serves a session's route, and an empty registry is a test artifact,
|
||||
* not a product state.
|
||||
*/
|
||||
class RouteOnlyAdapter extends LlmAdapter {
|
||||
constructor(private readonly providers: typeof REPLAY_PROVIDERS) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? [])
|
||||
.map(model => ({ provider, id: model.id, name: model.name })))
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const listed = this.providers.find(entry => entry.id === provider)?.models
|
||||
.find(entry => entry.id === model)
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: listed?.name ?? model,
|
||||
...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow },
|
||||
})
|
||||
}
|
||||
|
||||
override async *stream(): AsyncIterable<StreamChunk> {
|
||||
throw new Error(
|
||||
'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture'
|
||||
+ ' — pass replayFixture, or keep the scenario free of model calls',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
|
||||
if (contextWindow === undefined) return REPLAY_PROVIDERS
|
||||
return REPLAY_PROVIDERS.map(provider => ({
|
||||
@@ -390,6 +434,16 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
} else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
|
||||
// No fixture and no shipped adapter would leave the tree with ZERO
|
||||
// provider routes — a state no product composition has, and one the
|
||||
// composer now correctly refuses to type into. Register the same routes
|
||||
// a fixture would, with streaming that still fails loud: the scenario
|
||||
// issues no model calls, and one that slipped in must not pass quietly.
|
||||
ctx.effect(() => ctx.llm.registerAdapter(
|
||||
replayProviders(options.replayContextWindow).map(provider => provider.id),
|
||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
|
||||
), 'web e2e scaffold: route-only adapter')
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
|
||||
@@ -280,10 +280,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
|
||||
// This scenario deliberately leaves the LLM seam open to prove zero
|
||||
// model calls. History still restores the routed id, but without an
|
||||
// advertised catalog row the selector prompts for a listed replacement.
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
// This scenario issues zero model calls — the scaffold's route-only
|
||||
// adapter serves the catalog and refuses to stream — so history restores
|
||||
// the routed id and the seat resolves it against an advertised row.
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: Details
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Read Only"': Read Only
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok
|
||||
|
||||
Reference in New Issue
Block a user