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:
@@ -22,7 +22,7 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee
|
||||
import type { HostFrame } from '../src/api/index.ts'
|
||||
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
|
||||
import { RpcId } from '../src/api/rpc.ts'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
|
||||
|
||||
@@ -398,6 +398,25 @@ describe('settings domain', () => {
|
||||
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
|
||||
})
|
||||
|
||||
it('invalidates the model catalog when the gateway default route changes', async () => {
|
||||
const ctx = await harness()
|
||||
const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({
|
||||
provider: z.string().required(),
|
||||
model: z.string().required(),
|
||||
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
|
||||
const api = createApiProxy(ctx, DEFAULTS)
|
||||
// The gateway's own section names the route every session with no logged
|
||||
// one resolves to, so an externally edited default — another tab, a
|
||||
// hand-edited settings.yaml — has to reach an open selector as well.
|
||||
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
|
||||
await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
})
|
||||
expect(frames).toEqual([
|
||||
{ type: 'host/settings-changed', ns: 'api-gateway' },
|
||||
{ type: 'host/models-changed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
|
||||
const ctx = await harness()
|
||||
ctx.settings.register(NS, AdapterConfig)
|
||||
|
||||
108
packages/host/apiproxy/tests/api-proxy-default-route.spec.ts
Normal file
108
packages/host/apiproxy/tests/api-proxy-default-route.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The `api-gateway` settings section over a REAL settings provider: the
|
||||
* composition entry as the base layer, the wholesale replace the gateway
|
||||
* persists with, and the fallback when the provider detaches. The other model
|
||||
* specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so
|
||||
* this is the only place the layering itself is exercised.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts'
|
||||
import type { DefaultRouteSettings } from '../src/index.ts'
|
||||
|
||||
/** The smallest real provider: one in-memory document, always writable. */
|
||||
class MemorySettings extends Settings {
|
||||
doc: Record<string, unknown> = {}
|
||||
|
||||
get writable(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
protected load(): Promise<Record<string, unknown>> {
|
||||
return Promise.resolve(structuredClone(this.doc))
|
||||
}
|
||||
|
||||
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
|
||||
this.doc = { ...this.doc, [ns]: structuredClone(section) }
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the gateway's own section wiring over a live provider. */
|
||||
async function boot(entry: DefaultRouteSettings) {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(MemorySettings)
|
||||
await fiber.await()
|
||||
let route: () => DefaultRouteSettings = () => entry
|
||||
const consumer = ctx.plugin(function section(child: Context) {
|
||||
installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
|
||||
setSource: (current) => { route = current },
|
||||
onChange: () => {},
|
||||
})
|
||||
})
|
||||
await consumer.await()
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) throw new Error('settings provider did not mount')
|
||||
return { ctx, fiber, consumer, settings, read: () => route() }
|
||||
}
|
||||
|
||||
describe('the api-gateway default-route section', () => {
|
||||
it('resolves the composition entry until the user layer overrides it', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
expect(bench.read()).toEqual({
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('clears a stored effort when the next switch has none', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
|
||||
})
|
||||
expect(bench.read().reasoningEffort).toBe('high')
|
||||
|
||||
// The whole reason the gateway persists with `replace` rather than a merge
|
||||
// patch — and the reason `Config` carries no effort for the base layer to
|
||||
// re-inherit here. A stranded effort would fail the next session's first
|
||||
// request against a model that does not support it.
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-plain',
|
||||
})
|
||||
expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('layers a hand-written partial section over the entry', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
// Someone editing settings.yaml by hand may name only the model. The
|
||||
// entry supplies the provider, which is what makes this legal — and is
|
||||
// exactly why an effort in the entry could never be cleared, so there
|
||||
// is none to inherit.
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' })
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when the provider detaches', async () => {
|
||||
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
|
||||
provider: 'acme-gateway', model: 'acme-large',
|
||||
})
|
||||
expect(bench.read().provider).toBe('acme-gateway')
|
||||
|
||||
// A deployment that loses its settings provider keeps serving the route it
|
||||
// was composed with rather than the one it can no longer read.
|
||||
await bench.fiber.dispose()
|
||||
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await bench.ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
@@ -303,6 +303,37 @@ describe('Web session model selection', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
|
||||
cwd: '/tmp',
|
||||
workspaceRoot: '/tmp',
|
||||
})
|
||||
|
||||
// The client disabling its input is an affordance; this method stays
|
||||
// callable, so the refusal has to live here.
|
||||
const refused = await api.sessions.prompt(request({
|
||||
sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }],
|
||||
}))
|
||||
expect(refused.result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } },
|
||||
})
|
||||
expect(expectValue(await api.sessions.models(request({ sessionId }))).routable).toBe(false)
|
||||
|
||||
// An advisory-unlisted model on a live route is NOT this: the route
|
||||
// serves it, so the prompt goes through and nothing blocks.
|
||||
expectValue(await api.sessions.selectModel(request({
|
||||
sessionId, provider: 'deepseek-official', model: 'unlisted-but-served',
|
||||
})))
|
||||
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
|
||||
expect(catalog.routable).toBe(true)
|
||||
expect(catalog.groups.flatMap(group => group.models.map(model => model.id)))
|
||||
.not.toContain('unlisted-but-served')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
|
||||
const { ctx, sessionId } = await harness()
|
||||
const api = createApiProxy(ctx, {
|
||||
|
||||
@@ -45,6 +45,7 @@ function scriptedApi(overrides: {
|
||||
}),
|
||||
models: r => ok(r, {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}),
|
||||
|
||||
@@ -64,6 +64,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
ok: true,
|
||||
value: {
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
},
|
||||
|
||||
@@ -197,6 +197,7 @@ describe('sessions domain schemas', () => {
|
||||
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
|
||||
expect(sessionModelsValueSchema.parse({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
|
||||
routable: true,
|
||||
groups: [{
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
@@ -274,8 +275,10 @@ describe('sessions domain schemas', () => {
|
||||
describe('host domain schemas', () => {
|
||||
it('validates describe request/value', () => {
|
||||
expect(hostDescribeRequestSchema.parse({})).toEqual({})
|
||||
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', defaultTarget: () => ({ provider: 'p', model: 'm' }), attachedSessions: 2 })
|
||||
expect(value.attachedSessions).toBe(2)
|
||||
const value = hostDescribeValueSchema.parse({
|
||||
version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2,
|
||||
})
|
||||
expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2 })
|
||||
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user