Adapt session model selection to the slash/input/session architecture

- host trio kept on the merged api-proxy: session.models (provider-grouped
  advisory directory), session.selectModel (validated provider, advisory
  model), installAgentLlmTarget threaded through create/resume/ensureSession;
  the gateway declares the llm inject it reads
- history no longer piggybacks modelTarget: the current target travels on
  session.models alone (the /model popup is the sole consumer)
- new @deepseek-ai/dsh-client-ui-model plugin: /model popupSelect over the
  wire — options load the directory (group label in the detail column,
  provider-local failures listed inline), onSelect routes selectModel;
  failures ride the popup shell's error/retry surface
- ModelSelector package, conversation.composer.control slot, and the
  Session-side modelSelection state machine are removed: model selection
  belongs to the /model popup; the named conversation.input.model seat
  stays empty until a control-seat entry is designed for it
This commit is contained in:
imccyu
2026-07-27 10:28:45 +08:00
parent 6539c8d8fa
commit c132cbdb6a
27 changed files with 564 additions and 193 deletions

View File

@@ -2,8 +2,8 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -61,10 +61,23 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
() => Promise.resolve(ok({ events: [], hasMore: false }))
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
() => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel }))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: this.defaultModel,
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [],
}))
onSelectModel: (payload: { provider: string; model: string }) =>
Promise<RpcResponse<{ selected: ModelTarget }>> =
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
@@ -81,6 +94,9 @@ export class FakeApiClient implements IApiClient {
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
selectModel: (payload: { provider: string; model: string }) =>
this.record('session.selectModel', payload, this.onSelectModel(payload)),
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
}
@@ -110,12 +126,9 @@ export class FakeApiClient implements IApiClient {
// Payloads stay `unknown` (lint-lane note above); response rows are the real
// wire shapes so cases can program requires-bearing catalogs and dual-address
// skill lists without casts.
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
= () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
= () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
= () => Promise.resolve(ok({ skills: [] }))
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
readonly commands: IApiClient['commands'] = {
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),

View File

@@ -91,164 +91,6 @@ describe('open', () => {
})
})
describe('model selection', () => {
it('restores the current target from history, then refreshes grouped models with partial failures', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([])
api.onModels = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
}))
await session.open()
expect(session.getSnapshot().modelSelection).toEqual({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
status: 'idle',
error: null,
})
const refreshing = session.refreshModels()
expect(session.getSnapshot().modelSelection.status).toBe('loading')
await refreshing
expect(session.getSnapshot().modelSelection).toEqual({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
}],
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
status: 'ready',
error: null,
})
})
it('keeps the previous target and directory when selection fails, then accepts a retry', async () => {
const { api, session } = makeSession()
await session.open()
await session.refreshModels()
const before = session.getSnapshot().modelSelection
api.onSelectModel = () => Promise.resolve(err({
code: 'model-unavailable',
message: 'gone',
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
}))
const failed = session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(session.getSnapshot().modelSelection.status).toBe('selecting')
await failed
const errored = session.getSnapshot().modelSelection
expect(errored.current).toBe(before.current)
expect(errored.groups).toBe(before.groups)
expect(errored).toMatchObject({ status: 'error', error: { code: 'model-unavailable' } })
api.onSelectModel = payload => Promise.resolve(ok({
selected: { provider: payload.provider, model: payload.model },
}))
await session.retryModelOperation()
expect(api.callsOf('session.selectModel').at(-1)).toEqual({
sessionId: SID,
provider: 'deepseek',
model: 'deepseek-v4-pro',
})
expect(session.getSnapshot().modelSelection).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
status: 'ready',
error: null,
})
})
it('drops stale directory responses after a newer selection operation wins', async () => {
const { api, session } = makeSession()
await session.open()
const stale = deferred<Awaited<ReturnType<FakeApiClient['onModels']>>>()
api.onModels = () => stale.promise
const refreshing = session.refreshModels()
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
const selected = session.getSnapshot().modelSelection
stale.resolve(ok({
current: { provider: 'deepseek', model: 'stale' },
groups: [{ id: 'deepseek', name: 'Old', models: [{ id: 'stale', name: 'Stale' }] }],
failures: [],
}))
await refreshing
expect(session.getSnapshot().modelSelection).toBe(selected)
expect(session.getSnapshot().modelSelection.current)
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
})
it('does not let an older history response overwrite a newer selected target', async () => {
const { api, session } = makeSession()
const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => history.promise
const opening = session.open()
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
history.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await opening
expect(session.getSnapshot().modelSelection.current)
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
})
it('folds directory and selection transport failures without discarding usable state', async () => {
const { api, session } = makeSession()
await session.open()
await session.refreshModels()
const groups = session.getSnapshot().modelSelection.groups
api.onModels = () => Promise.reject(new Error('directory transport down'))
await session.refreshModels()
expect(session.getSnapshot().modelSelection).toMatchObject({
groups,
status: 'error',
error: { code: 'internal', message: 'directory transport down' },
})
api.onModels = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [...groups],
failures: [],
}))
await session.retryModelOperation()
expect(session.getSnapshot().modelSelection.status).toBe('ready')
api.onSelectModel = () => Promise.reject(new Error('selection transport down'))
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(session.getSnapshot().modelSelection).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups,
status: 'error',
error: { code: 'internal', message: 'selection transport down' },
})
})
it('reconciles a failed selection from authoritative history on reconnect', async () => {
const { api, session } = makeSession()
await session.open()
api.onSelectModel = () => Promise.reject(new Error('lost response'))
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
expect(session.getSnapshot().modelSelection.status).toBe('error')
api.onHistory = () => Promise.resolve(ok({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-pro' },
}))
await session.resync()
expect(session.getSnapshot().modelSelection).toMatchObject({
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
status: 'idle',
error: null,
})
})
})
describe('live event path', () => {
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
@@ -949,7 +791,6 @@ describe('reference stability (the memo contract)', () => {
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
expect(after.modelSelection).toBe(before.modelSelection)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
const resolved = session.getSnapshot()