feat(web): add session model selector

This commit is contained in:
Yichen Jiang
2026-07-24 14:55:54 +08:00
parent bc7a89b81f
commit 208a44a7ec
87 changed files with 2236 additions and 87 deletions

View File

@@ -7,7 +7,8 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
export type {

View File

@@ -9,7 +9,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -295,6 +295,10 @@ export function createFixtureApi(): ApiProxy {
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, cwd: '/tmp/fixture' },
]
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
session.sessionId,
{ provider: 'deepseek', model: 'deepseek-v4-flash' },
]))
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
let nextSession = 1
let nextRpc = 1
@@ -469,6 +473,7 @@ export function createFixtureApi(): ApiProxy {
sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, cwd: '/tmp/fixture',
}
sessions.push(created)
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
emitHost({ type: 'host/session-added', sessionId: created.sessionId })
return ok(request, { sessionId: created.sessionId })
},
@@ -481,7 +486,36 @@ export function createFixtureApi(): ApiProxy {
const delay = historyDelayMs
if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
if (doomed) throw new Error('fixture: simulated history transport failure')
return ok(request, page)
return ok(request, {
...page,
modelTarget: modelTargets.get(request.payload.sessionId)
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
},
models: request => ok(request, {
current: modelTargets.get(request.payload.sessionId)
?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5' }],
},
],
failures: [],
}),
selectModel: (request) => {
const selected = { provider: request.payload.provider, model: request.payload.model }
modelTargets.set(request.payload.sessionId, selected)
return ok(request, { selected })
},
prompt: (request) => {
const { sessionId: id, mode, content } = request.payload
@@ -508,7 +542,9 @@ export function createFixtureApi(): ApiProxy {
turn,
userText === 'render markdown'
? MARKDOWN_FIXTURE
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
: userText === 'report model'
? `当前模型:${modelTargets.get(id)?.provider ?? 'unknown'}/${modelTargets.get(id)?.model ?? 'unknown'}`
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
)
return ok(request, { accepted: true as const })
},
@@ -631,6 +667,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.list': return this.api.sessions.list(request)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
case 'session.selectModel': return this.api.sessions.selectModel(request)
case 'session.prompt': return this.api.sessions.prompt(request)
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)

View File

@@ -15,7 +15,8 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ModelCatalogFailure,
ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels, ToolEventView,
ToolCallView, ToolResultView,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,

View File

@@ -2,7 +2,7 @@
// data source on a real clock; behavior tests need per-case responses and
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
import type {
HostFrame, IApiClient, MuxFrame, RpcRequest, RpcResponse, SessionId,
HostFrame, IApiClient, ModelTarget, MuxFrame, RpcRequest, RpcResponse, SessionId, SessionModels,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,9 +44,21 @@ export class FakeApiClient implements IApiClient {
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 }))
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: { provider: 'deepseek', model: 'deepseek-chat' },
}))
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
current: { provider: 'deepseek', model: 'deepseek-chat' },
groups: [],
failures: [],
}))
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
=> 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 }>> =
@@ -63,6 +75,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: ModelTarget & { sessionId: SessionId }) =>
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)),
}

View File

@@ -68,7 +68,43 @@ describe('createFixtureApi', () => {
// Unknown session: empty page, not an error (history of a bare id).
const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 }))
if (!empty.result.ok) throw new Error('empty failed')
expect(empty.result.value).toEqual({ events: [], hasMore: false })
expect(empty.result.value).toEqual({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
})
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
const api = createFixtureApi()
const sessionId = sid('fx-alpha')
const catalog = await api.sessions.models(req({ sessionId }))
if (!catalog.result.ok) throw new Error('models failed')
expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI'])
expect(catalog.result.value.groups[0]?.models.map(model => model.id))
.toEqual(['deepseek-v4-flash', 'deepseek-v4-pro'])
const selected = await api.sessions.selectModel(req({
sessionId,
provider: 'openai',
model: 'gpt-5',
}))
if (!selected.result.ok) throw new Error('selection failed')
expect(selected.result.value.selected).toEqual({ provider: 'openai', model: 'gpt-5' })
const history = await api.sessions.history(req({ sessionId }))
if (!history.result.ok) throw new Error('history failed')
expect(history.result.value.modelTarget).toEqual({ provider: 'openai', model: 'gpt-5' })
const prompt = await api.sessions.prompt(req({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'report model' }],
}))
expect(prompt.result.ok).toBe(true)
await new Promise(resolve => setTimeout(resolve, 600))
const after = await api.sessions.history(req({ sessionId }))
if (!after.result.ok) throw new Error('history failed')
expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5')
})
it('create adds a session and pushes host/session-added to open host streams', async () => {

View File

@@ -5,7 +5,7 @@
* `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis
* fiber in place. Every graph entry is a plugin bundle under the web2 model
* — `immediately` rows differ only in stage-one prefetch (a boot
* optimization), so all nine plugin packages share these reload semantics;
* optimization), so all rostered plugin packages share these reload semantics;
* normal packages (react family, cordis, shell, pure libs) are not entries
* and shell changes still mean a page reload. Cascade is zero-touch:
* downstream fibers key their activation epoch on provider fiber uids

View File

@@ -6,13 +6,17 @@ Client cordis boot + core services: SlotsService (Service wrapper over SlotCore
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
## Session model selection
Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
## Model Experience
None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request.
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
## Known Limitations and Deferred Work

View File

@@ -32,7 +32,7 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
RunningToolCall, SteeringMessageNode,
ModelSelectionSnapshot, ModelSelectionStatus, RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.

View File

@@ -4,7 +4,10 @@
// string here (narrow to real brands when convenient).
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type {
ModelCatalogFailure, ModelProviderGroup, ModelTarget, RpcError, SessionId,
ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
/** Assistant content blocks sorted by what the UI cares about
@@ -139,6 +142,23 @@ export interface PromptError {
error: RpcError
}
/** Lifecycle of the session-local model directory and selection requests. */
export type ModelSelectionStatus = 'idle' | 'loading' | 'ready' | 'selecting' | 'error'
/** Immutable model-selector state owned by the Session object layer. */
export interface ModelSelectionSnapshot {
/** Target selected for the next assembled step, or null before history opens. */
current: ModelTarget | null
/** Last successfully loaded provider groups. */
groups: readonly ModelProviderGroup[]
/** Provider-local failures from the last successful directory response. */
failures: readonly ModelCatalogFailure[]
/** Current directory or selection operation state. */
status: ModelSelectionStatus
/** Whole-request or selection failure; partial provider failures use {@link failures}. */
error: RpcError | null
}
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
@@ -158,4 +178,6 @@ export interface ConversationSnapshot {
loadingOlder: boolean
promptError: PromptError | null
lastAgentError: string | null
/** Session-local model target and advisory directory state. */
modelSelection: ModelSelectionSnapshot
}

View File

@@ -6,15 +6,16 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, ToolEventView,
HistoryEntry, IApiClient, ModelTarget, MuxFrame, RpcError, RpcId, RpcResult,
SessionId, SessionModels, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
ConversationNode, ConversationSnapshot, ModelSelectionSnapshot, OpenState, PromptError,
RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -69,6 +70,17 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
private modelSelection: ModelSelectionSnapshot = {
current: null,
groups: [],
failures: [],
status: 'idle',
error: null,
}
/** Latest model-directory/selection operation; stale responses drop all writes. */
private modelGeneration = 0
/** Failed selection target; null means the retryable operation is a directory refresh. */
private modelRetryTarget: ModelTarget | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
@@ -128,6 +140,102 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return result
}
/**
* Refresh the advisory provider/model directory. Independent provider
* failures remain in a successful snapshot; whole-request failures preserve
* the last usable groups and current target.
* @returns the model-directory RPC result.
*/
async refreshModels(): Promise<RpcResult<SessionModels>> {
const generation = ++this.modelGeneration
this.modelRetryTarget = null
this.modelSelection = {
...this.modelSelection,
status: 'loading',
error: null,
}
this.notifier.markDirty()
let result: RpcResult<SessionModels>
try {
result = (await this.api.sessions.models({ sessionId: this.sessionId })).result
} catch (error: unknown) {
result = transportError(error)
}
if (generation !== this.modelGeneration) return result
this.modelSelection = result.ok
? {
current: result.value.current,
groups: result.value.groups,
failures: result.value.failures,
status: 'ready',
error: null,
}
: {
...this.modelSelection,
status: 'error',
error: result.error,
}
this.notifier.markDirty()
return result
}
/**
* Select the complete route for this session. The host snapshots it at the
* next prompt-assembly boundary, so running work keeps its assembled target.
* @param target - Provider and provider-owned model id.
* @returns the selection RPC result.
*/
async selectModel(target: ModelTarget): Promise<RpcResult<{ selected: ModelTarget }>> {
const generation = ++this.modelGeneration
this.modelRetryTarget = target
this.modelSelection = {
...this.modelSelection,
status: 'selecting',
error: null,
}
this.notifier.markDirty()
let result: RpcResult<{ selected: ModelTarget }>
try {
result = (await this.api.sessions.selectModel({
sessionId: this.sessionId,
provider: target.provider,
model: target.model,
})).result
} catch (error: unknown) {
result = transportError(error)
}
if (generation !== this.modelGeneration) return result
if (result.ok) this.modelRetryTarget = null
this.modelSelection = result.ok
? {
...this.modelSelection,
current: result.value.selected,
status: 'ready',
error: null,
}
: {
...this.modelSelection,
status: 'error',
error: result.error,
}
this.notifier.markDirty()
return result
}
/**
* Repeat the operation that produced the visible model error.
* A failed selection retains its exact target; directory failures refresh.
* @returns Whether a model selection succeeded; directory retries return false.
*/
async retryModelOperation(): Promise<boolean> {
const target = this.modelRetryTarget
if (target === null) {
await this.refreshModels()
return false
}
return (await this.selectModel(target)).ok
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
@@ -318,6 +426,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = null
this.notifier.markDirty()
try {
let modelGeneration = this.modelGeneration
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
@@ -325,13 +434,24 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
modelGeneration = this.modelGeneration
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
if (result.ok) {
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
}
}
this.openState = 'open'
} catch (error) {
@@ -349,13 +469,30 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
private installWindow(entries: HistoryEntry[], hasMore: boolean, modelTarget?: ModelTarget): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
if (modelTarget !== undefined) {
const current = this.modelSelection.current
if (
current === null
|| current.provider !== modelTarget.provider
|| current.model !== modelTarget.model
|| this.modelSelection.error !== null
) {
this.modelRetryTarget = null
this.modelSelection = {
...this.modelSelection,
current: modelTarget,
status: this.modelSelection.groups.length > 0 ? 'ready' : 'idle',
error: null,
}
}
}
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
@@ -400,11 +537,16 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.stitching) return
this.stitching = true
const generation = this.openGeneration
const modelGeneration = this.modelGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(
result.value.events,
result.value.hasMore,
modelGeneration === this.modelGeneration ? result.value.modelTarget : undefined,
)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -539,6 +681,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
loadingOlder: this.loadingOlder,
promptError: this.promptError,
lastAgentError: this.lastAgentError,
modelSelection: this.modelSelection,
}
}
}

View File

@@ -2,7 +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, HostFrame, IApiClient, MuxFrame, RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId,
ClientResponse, HostFrame, IApiClient, ModelTarget, MuxFrame, RpcError, RpcReceipt,
RpcRequest, RpcResponse, SessionId, SessionModels,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -46,10 +47,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 }>> =
@@ -66,6 +80,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)),
}

View File

@@ -267,7 +267,11 @@ describe('remaining branches', () => {
describe('connected generation', () => {
it('refreshes the list and resyncs only opened instances', async () => {
const api = new FakeApiClient()
api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
api.onHistory = () => Promise.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-chat' },
}))
const manager = new SessionManager(api)
const openedSession = manager.get(S1)
await openedSession.open()

View File

@@ -24,7 +24,11 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session:
function histResponse(events: SessionEvent[], hasMore = false) {
// history now returns HistoryEntry[] ({event, view?}); these tests are view-less.
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
return Promise.resolve(ok({
events: entries(events) as never[],
hasMore,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
}
describe('open', () => {
@@ -75,7 +79,11 @@ describe('open', () => {
const page = plainTurn(10, 0, '早', '安')
session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) })
session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') })
gate.resolve(ok({ events: entries(page) as never[], hasMore: false }))
gate.resolve(ok({
events: entries(page) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await opening
const seqs = session.getSnapshot().nodes.map(n => n.seq)
// Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once.
@@ -83,6 +91,165 @@ 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')) {
const { api, session } = makeSession()
@@ -210,7 +377,11 @@ describe('paging', () => {
api.onHistory = () => gate.promise
const first = session.loadOlder()
const second = session.loadOlder()
gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false }))
gate.resolve(ok({
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await Promise.all([first, second])
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
})
@@ -482,7 +653,11 @@ describe('remaining branches', () => {
const opening = session.open()
api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代'))
const resynced = session.resync()
stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone
stale.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
})) // success, but its generation is gone
await Promise.all([opening, resynced])
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window
})
@@ -501,7 +676,11 @@ describe('remaining branches', () => {
const opening = session.open() // triggers the second pull, which parks
await vi.waitFor(() => { expect(call).toBe(2) })
const resynced = session.resync()
secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false }))
secondPull.resolve(ok({
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
}))
await Promise.all([opening, resynced])
expect(session.getSnapshot().openState).toBe('open')
})
@@ -515,7 +694,11 @@ describe('remaining branches', () => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap
api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd'))
const resynced = session.resync() // bumps the generation
repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped
repairPull.resolve(ok({
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'stale' },
})) // repair result: stale, dropped
await resynced
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
})
@@ -559,6 +742,7 @@ describe('remaining branches', () => {
{ event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
] as never[],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await session.open()
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
@@ -662,6 +846,7 @@ 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()

View File

@@ -10,6 +10,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
The resident composer declares the session-scoped single slot `'conversation.composer.control'` and renders its occupant immediately before the send/stop button. Feature packages own the control and its state; ui-conversation supplies only the placement and standard slot shares. The new-session empty-state composer deliberately has no corresponding control slot.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience

View File

@@ -74,6 +74,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.control': { kind: 'single', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {

View File

@@ -41,6 +41,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/**
* Optional controls rendered in the resident InputBar immediately before
* its primary send/stop button. The conversation entry declares and owns
* the render site; feature plugins contribute the single occupant.
*/
'conversation.composer.control': {
kind: 'single'
scope: 'session'
owner: ComposerControlOwnerProps
}
}
}
@@ -127,9 +137,13 @@ export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
/** Composer-control owner share; session state and actions arrive through the standard and injected shares. */
export interface ComposerControlOwnerProps {}
/** Full conversation-slot component props: runtime & child-render shares & store & injected shares. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
PropsRuntime<'conversation'>
& PropsRenderSlots<'conversation.view' | 'conversation.composer' | 'conversation.composer.control'>
& PropsStore<ChatStore> & ConversationInjected
/**

View File

@@ -18,8 +18,8 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ComposerControlOwnerProps,
ConversationInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -68,6 +68,7 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
control={renderSlot('conversation.composer.control', {})}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}

View File

@@ -124,9 +124,14 @@
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 0 10px 10px 12px;
}
.control {
min-width: 0;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
#679EFE dark — the info-fill pair (500→400), NOT button-primary (ink);
white glyph; empty text = 0.4 opacity. */

View File

@@ -26,13 +26,16 @@ export interface InputBarProps {
placeholder?: string
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
/** Optional resident composer control rendered immediately before the primary button. */
control?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
export function InputBar({
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
draft, running, disabled, error, variant, placeholder, accessory, control,
onDraftChange, onSend, onStop,
}: InputBarProps) {
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
@@ -116,6 +119,7 @@ export function InputBar({
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
{control !== undefined && <div className={css.control}>{control}</div>}
<button
type="button"
className={clsx(css.primary, running && css.stopping)}

View File

@@ -29,6 +29,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}

View File

@@ -41,7 +41,8 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */

View File

@@ -31,6 +31,7 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}

View File

@@ -26,7 +26,8 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
describe('render branch tails', () => {

View File

@@ -128,4 +128,11 @@ describe('error strip and variants', () => {
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders the optional composer control immediately before the primary button', () => {
const { view } = setup({ control: <button type="button">model</button> })
const buttons = view.getAllByRole('button')
expect(buttons.map(button => button.textContent)).toEqual(['model', ''])
expect(buttons[1]?.getAttribute('aria-label')).toBe('发送')
})
})

View File

@@ -30,7 +30,8 @@ function snapshotBase(): ConversationSnapshot {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
modelSelection: { current: null, groups: [], failures: [], status: 'idle', error: null },
}
}
function sessionSource(over?: Partial<ConversationSnapshot>) {
@@ -58,7 +59,9 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
describe('ConversationRoot branches', () => {
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
const stubRenderSlot = ((key: string) =>
key === 'conversation.view' ? <div data-testid="view-body" /> : null
) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>

View File

@@ -177,8 +177,11 @@ describe('ConversationRoot', () => {
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([tab('chat', 'Chat')])
const { chat, send, renderSlot } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
expect(renderSlot).toHaveBeenCalledWith('conversation.composer.control', {})
expect(screen.getByTestId('view-(all)').getAttribute('data-slot'))
.toBe('conversation.composer.control')
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.

View File

@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-client-ui-model-selector
Session-scoped Web model selector. Its browser half occupies `conversation.composer.control`, shows the current catalog name beside the send button, and opens an upward provider-grouped menu. Provider names appear once as group headings; model rows and the trigger show catalog names without repeating the provider route, with the model id as the fallback for an unlisted current target.
The selector primes the advisory directory when it mounts so the trigger can resolve the catalog name, then refreshes it whenever the menu opens. The Session object layer owns loading, selection, partial-provider-failure, and stale-response state. A selection updates only that live session and takes effect at the next prompt-assembly boundary, including while the current step is running. The latest consumed target remains durable through the existing `request/header`; an unused choice is process-local.
Catalog membership is not request validation. The current target is included as an unlisted row when its registered provider omits it, while a target whose provider is unavailable remains visible on the trigger with a warning in the menu.
## Model Experience
None, as the browser selector changes subsequent request routing but adds no model-visible content.
#### KV Cache effect
Switching routes may invalidate provider-side cache reuse according to the selected adapter. The selector itself adds no prompt content.
## Known Limitations and Deferred Work
- **The new-session composer has no selector** — a session starts with the host default and exposes the selector after creation.
- **Unused selections are not durable** — reload restores the last route consumed by a request, not a choice made without sending.

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-client-ui-model-selector",
"description": "Session-scoped provider/model selector for the Web conversation composer",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,197 @@
.root {
position: relative;
min-width: 0;
}
.trigger {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 220px;
height: 34px;
padding: 0 6px;
border: none;
border-radius: 8px;
outline: none;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 22px;
font-weight: 500;
cursor: pointer;
}
.trigger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
.trigger:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
transition: transform 120ms ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
display: flex;
flex-direction: column;
width: min(320px, calc(100vw - 32px));
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 6px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 14px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
}
.status,
.empty {
padding: 10px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
}
.error,
.warning {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
padding: 7px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.warning {
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-state-warn-label);
}
.retry {
flex: 0 0 auto;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.groups {
min-height: 0;
overflow-y: auto;
}
.group + .group {
margin-top: 4px;
}
.groupTitle {
position: sticky;
top: 0;
z-index: 1;
padding: 5px 8px 3px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
font-weight: 500;
}
.option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 38px;
padding: 6px 8px;
border: none;
border-radius: 10px;
outline: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.option:hover:not(:disabled),
.option:focus-visible,
.selected {
background: var(--dsw-alias-interactive-bg-hover);
}
.option:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
}
.optionCopy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.modelName {
overflow: hidden;
color: inherit;
font-size: 14px;
line-height: 20px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.description,
.unlisted {
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.unlisted {
color: var(--dsw-alias-state-warn-label);
}
.check {
display: grid;
place-items: center;
flex: 0 0 18px;
color: var(--dsw-alias-state-business-primary);
}

View File

@@ -0,0 +1,261 @@
import {
useEffect, useId, useMemo, useRef, useState,
type FocusEvent, type KeyboardEvent,
} from 'react'
import clsx from 'clsx'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import {
IconCheckOutline16, IconChevronDownOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ModelSelectorProps } from './contract.ts'
import css from './ModelSelector.module.css'
type FocusPreference = 'current' | 'first' | 'last'
/** Session-scoped provider-grouped model selector for the composer action row. */
export function ModelSelector({
useSession, refreshModels, retryModelOperation, selectModel,
}: ModelSelectorProps) {
const selection = useSession(snapshot => snapshot.modelSelection)
const removed = useSession(snapshot => snapshot.removed)
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const pendingFocus = useRef<FocusPreference | null>(null)
const id = useId()
const { choices, choiceIndices } = useMemo(() => {
const nextChoices = selection.groups.flatMap(group =>
group.models.map(model => ({
group,
model,
target: { provider: group.id, model: model.id } satisfies ModelTarget,
})))
return {
choices: nextChoices,
choiceIndices: new Map(nextChoices.map((choice, index) => [
JSON.stringify([choice.target.provider, choice.target.model]),
index,
])),
}
}, [selection.groups])
const selectedIndex = selection.current === null
? -1
: choiceIndices.get(JSON.stringify([
selection.current.provider,
selection.current.model,
])) ?? -1
const busy = selection.status === 'selecting'
useEffect(() => {
refreshModels()
}, [refreshModels])
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
useEffect(() => {
const preference = pendingFocus.current
if (!open || preference === null || choices.length === 0) return
const index = preference === 'first'
? 0
: preference === 'last'
? choices.length - 1
: selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
pendingFocus.current = null
}, [choices.length, open, selectedIndex])
const show = (preference: FocusPreference | null = null): void => {
pendingFocus.current = preference
setOpen(true)
refreshModels()
}
const close = (restoreFocus = false): void => {
setOpen(false)
pendingFocus.current = null
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
const moveFocus = (offset: number): void => {
if (choices.length === 0) return
const active = itemRefs.current.findIndex(item => item === document.activeElement)
const origin = active >= 0 ? active : selectedIndex >= 0 ? selectedIndex : 0
const next = (origin + offset + choices.length) % choices.length
itemRefs.current[next]?.focus()
}
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && open) {
event.preventDefault()
close(true)
return
}
if (!open) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
itemRefs.current[event.key === 'Home' ? 0 : choices.length - 1]?.focus()
}
}
const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
event.preventDefault()
if (!open) {
show(event.key === 'ArrowDown' ? 'first' : 'last')
return
}
pendingFocus.current = 'current'
const index = selectedIndex >= 0 ? selectedIndex : 0
itemRefs.current[index]?.focus()
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
close()
}
const choose = (target: ModelTarget): void => {
if (
selection.current?.provider === target.provider
&& selection.current.model === target.model
) {
close(true)
return
}
void selectModel(target).then((accepted) => {
if (accepted && rootRef.current !== null) close(true)
})
}
const retry = (): void => {
void retryModelOperation().then((selected) => {
if (selected && rootRef.current !== null) close(true)
})
}
const currentProviderKnown = selection.current === null
|| selection.groups.some(group => group.id === selection.current?.provider)
|| selection.failures.some(failure => failure.id === selection.current?.provider)
const label = choices[selectedIndex]?.model.name ?? selection.current?.model ?? '选择模型'
return (
<div
ref={rootRef}
className={css.root}
onKeyDown={onRootKeyDown}
onBlur={onBlur}
>
<button
ref={triggerRef}
type="button"
className={css.trigger}
aria-label={`选择模型,当前 ${label}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${id}-menu` : undefined}
title={label}
disabled={removed}
onClick={() => { open ? close() : show() }}
onKeyDown={onTriggerKeyDown}
>
<span className={css.triggerLabel}>{label}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
<div
id={`${id}-menu`}
className={css.menu}
role="menu"
aria-label="模型"
aria-busy={selection.status === 'loading' || busy}
>
{selection.status === 'loading' && (
<div className={css.status}></div>
)}
{selection.error !== null && (
<div className={css.error}>
<span>{selection.error.message}</span>
<button type="button" className={css.retry} onClick={retry}></button>
</div>
)}
{selection.failures.map(failure => (
<div className={css.warning} key={failure.id}>
<span>{failure.name} {failure.message}</span>
<button type="button" className={css.retry} onClick={refreshModels}></button>
</div>
))}
{selection.status !== 'loading' && !currentProviderKnown && selection.current !== null && (
<div className={css.warning}>
{selection.current.provider}
</div>
)}
<div className={clsx(css.groups, 'scrollable')}>
{selection.groups.map((group) => {
const headingId = `${id}-${group.id}`
return (
<section
role="group"
aria-labelledby={headingId}
className={css.group}
key={group.id}
>
<div className={css.groupTitle} id={headingId}>{group.name}</div>
{group.models.map((model) => {
const index = choiceIndices.get(JSON.stringify([group.id, model.id])) ?? -1
const selected = selection.current?.provider === group.id
&& selection.current.model === model.id
return (
<button
ref={(node) => { itemRefs.current[index] = node }}
type="button"
role="menuitemradio"
aria-checked={selected}
className={clsx(css.option, selected && css.selected)}
key={model.id}
title={model.name}
disabled={busy}
onClick={() => { choose({ provider: group.id, model: model.id }) }}
>
<span className={css.optionCopy}>
<span className={css.modelName}>{model.name}</span>
{model.description !== undefined && (
<span className={css.description}>{model.description}</span>
)}
{model.unlisted === true && (
<span className={css.unlisted}> · </span>
)}
</span>
<span className={css.check}>
{selected ? <IconCheckOutline16 /> : null}
</span>
</button>
)
})}
</section>
)
})}
</div>
{selection.status === 'ready' && choices.length === 0 && (
<div className={css.empty}></div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,28 @@
/**
* Model-selector slot contract: standard session props plus the plain
* object-layer actions injected by this package's registration.
*/
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Plain callbacks contributed by the selector registration. */
export interface ModelSelectorInjected {
/** Refresh the session's advisory model directory. */
refreshModels(): void
/**
* Retry the directory refresh or exact selection that produced the visible operation error.
* @returns Whether a model selection succeeded and the menu should close.
*/
retryModelOperation(): Promise<boolean>
/**
* Select a complete provider/model target.
* @param target - Target selected from one provider group.
* @returns Whether the host accepted the selection.
*/
selectModel(target: ModelTarget): Promise<boolean>
}
/** Full props of the conversation composer-control occupant. */
export type ModelSelectorProps =
PropsRuntime<'conversation.composer.control'> & ModelSelectorInjected

View File

@@ -0,0 +1,33 @@
/**
* Browser model-selector plugin: registers one session-scoped occupant in the
* conversation composer-control slot. The Session object owns all catalog and
* selection state; the component receives only the standard snapshot hook and
* injected callbacks.
*/
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelSelectorInjected } from './contract.ts'
import { ModelSelector } from './ModelSelector.tsx'
export type { ModelSelectorInjected, ModelSelectorProps } from './contract.ts'
/** Required services; conversation is the slot-declaration ordering edge. */
export const inject = ['slots', 'sessions', 'conversation']
/**
* Register the model selector in the resident conversation composer.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
ctx.slots.register({
name: 'conversation.composer.control',
inject: (sessionId: SessionId): ModelSelectorInjected => {
const session = sessions.manager.get(sessionId)
return {
refreshModels: () => { void session.refreshModels() },
retryModelOperation: () => session.retryModelOperation(),
selectModel: async target => (await session.selectModel(target)).ok,
}
},
}, ModelSelector)
}

View File

@@ -0,0 +1,4 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}

View File

@@ -0,0 +1,14 @@
/**
* Web model-selector plugin, node half. Model routing and catalog RPCs belong
* to the host runtime, so this package contributes no host registration.
*/
import type { Context } from 'cordis'
/** No host services are required. */
export const inject: string[] = []
/**
* Empty host half for the browser-only selector feature.
* @param _ctx - Host plugin context.
*/
export function apply(_ctx: Context): void {}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model-selector`.
* @module @deepseek-ai/dsh-client-ui-model-selector/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model-selector'
/** Cordis companion plugin name. */
export const name = 'client-ui-model-selector-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the slot registry owns selector registration
* lifecycle, and the wire/object-layer tests own model-target consistency.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns The installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,83 @@
/**
* Browser-plugin assembly: the selector occupies the conversation-declared
* composer-control slot, injects only Session object actions, fails loud when
* the slot is undeclared, and unregisters with its plugin fiber.
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ModelSelector } from '../src/client/ModelSelector.tsx'
import { apply, inject } from '../src/client/index.ts'
const SID = 'selector-session' as SessionId
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
slots.register({
name: 'root',
children: {
'conversation.composer.control': { kind: 'single', scope: 'session' },
},
} as never, (_props: { renderSlot?: unknown }) => null)
const session = {
refreshModels: vi.fn(() => Promise.resolve({ ok: true })),
retryModelOperation: vi.fn(() => Promise.resolve(true)),
selectModel: vi.fn((target: { provider: string; model: string }) => Promise.resolve({
ok: target.model !== 'rejected',
value: { selected: target },
})),
}
ctx.provide('sessions', { manager: { get: () => session } })
ctx.provide('conversation', {})
return { ctx, slots, session }
}
describe('model-selector browser plugin', () => {
it('declares its ordering and service dependencies', () => {
expect(inject).toEqual(['slots', 'sessions', 'conversation'])
})
it('fails loud when the conversation control slot is not declared', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('sessions', { manager: { get: vi.fn() } })
ctx.provide('conversation', {})
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation\.composer\.control" is not declared/)
})
it('registers the singleton and injects Session-owned refresh/select actions', async () => {
const { ctx, slots, session } = await bench()
await ctx.plugin({ inject: [...inject], apply }).await()
const entries = slots.entries('conversation.composer.control')
expect(entries).toHaveLength(1)
expect(entries[0]?.component).toBe(ModelSelector)
const injected = (entries[0]?.inject as (sessionId: SessionId) => {
refreshModels(): void
retryModelOperation(): Promise<boolean>
selectModel(target: { provider: string; model: string }): Promise<boolean>
})(SID)
injected.refreshModels()
expect(session.refreshModels).toHaveBeenCalledTimes(1)
await expect(injected.retryModelOperation()).resolves.toBe(true)
expect(session.retryModelOperation).toHaveBeenCalledTimes(1)
await expect(injected.selectModel({ provider: 'deepseek', model: 'deepseek-chat' }))
.resolves.toBe(true)
await expect(injected.selectModel({ provider: 'deepseek', model: 'rejected' }))
.resolves.toBe(false)
})
it('unregisters the occupant when its plugin fiber is disposed', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('conversation.composer.control')).toHaveLength(1)
await fiber.dispose()
expect(slots.entries('conversation.composer.control')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,193 @@
// @vitest-environment jsdom
/**
* Provider-grouped selector behavior: compact model-only trigger, grouped
* radio menu, retry/error states, successful and failed selection, outside
* dismissal, and keyboard focus navigation.
*/
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
ConversationSnapshot, ModelSelectionSnapshot,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { ModelSelectorProps } from '../src/client/contract.ts'
import { ModelSelector } from '../src/client/ModelSelector.tsx'
afterEach(cleanup)
const ready: ModelSelectionSnapshot = {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: '快速响应' },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', description: '复杂任务' },
],
},
{
id: 'openai',
name: 'OpenAI',
models: [{ id: 'gpt-5', name: 'GPT-5' }],
},
],
failures: [],
status: 'ready',
error: null,
}
function setup(selection: ModelSelectionSnapshot = ready, removed = false) {
let current = { modelSelection: selection, removed } as unknown as ConversationSnapshot
const useSession = ((selector: (snapshot: ConversationSnapshot) => unknown) =>
selector(current)) as ModelSelectorProps['useSession']
const refreshModels = vi.fn()
const retryModelOperation = vi.fn(() => Promise.resolve(false))
const selectModel = vi.fn((_target: ModelTarget) => Promise.resolve(true))
const props: ModelSelectorProps = {
sessionId: 'selector-session' as never,
useSession,
useSessions: ((selector: (snapshot: never) => unknown) =>
selector({} as never)) as ModelSelectorProps['useSessions'],
refreshModels,
retryModelOperation,
selectModel,
}
const view = render(<ModelSelector {...props} />)
return {
view,
refreshModels,
retryModelOperation,
selectModel,
update(next: ModelSelectionSnapshot, nextRemoved = removed) {
current = { modelSelection: next, removed: nextRemoved } as unknown as ConversationSnapshot
view.rerender(<ModelSelector {...props} />)
},
}
}
function trigger(): HTMLButtonElement {
return screen.getByRole('button', { name: /选择模型,当前/ })
}
describe('model selector', () => {
it('shows the catalog name, opens upward into provider groups, and marks the current radio item', () => {
const { refreshModels } = setup()
expect(refreshModels).toHaveBeenCalledTimes(1)
expect(trigger().textContent).toBe('DeepSeek-V4-Flash')
expect(trigger().textContent).not.toContain('deepseek/')
expect(trigger().title).toBe('DeepSeek-V4-Flash')
fireEvent.click(trigger())
expect(refreshModels).toHaveBeenCalledTimes(2)
expect(screen.getByRole('menu', { name: '模型' })).toBeTruthy()
expect(screen.getAllByRole('group')).toHaveLength(2)
expect(screen.getByText('DeepSeek')).toBeTruthy()
expect(screen.getByText('OpenAI')).toBeTruthy()
const rows = screen.getAllByRole('menuitemradio')
expect(rows.map(row => row.querySelector('[class*="modelName"]')?.textContent))
.toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro', 'GPT-5'])
expect(rows[0]?.getAttribute('aria-checked')).toBe('true')
expect(rows[1]?.getAttribute('aria-checked')).toBe('false')
expect(rows.some(row => row.textContent?.includes('deepseek/deepseek'))).toBe(false)
})
it('keeps the menu open on failure, closes after success, and closes current selection without an RPC', async () => {
const { selectModel } = setup()
selectModel.mockResolvedValueOnce(false)
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
await waitFor(() => { expect(selectModel).toHaveBeenCalledWith({ provider: 'deepseek', model: 'deepseek-v4-pro' }) })
expect(screen.getByRole('menu')).toBeTruthy()
selectModel.mockResolvedValueOnce(true)
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Flash/ }))
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
expect(selectModel).toHaveBeenCalledTimes(2)
})
it('renders loading, empty, partial-provider, operation-error, and unavailable-current states with retries', () => {
const error = { code: 'internal' as const, message: 'wire down', details: {} }
const { refreshModels, retryModelOperation, update } = setup({
...ready,
current: { provider: 'missing', model: 'private-preview-with-a-very-long-name' },
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
status: 'error',
error,
})
expect(trigger().textContent).toBe('private-preview-with-a-very-long-name')
fireEvent.click(trigger())
expect(screen.getByText(/模型操作失败wire down/)).toBeTruthy()
expect(screen.getByText(/Offline 加载失败catalog down/)).toBeTruthy()
expect(screen.getByText(/当前提供方 missing 未注册/)).toBeTruthy()
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[0]!)
expect(retryModelOperation).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getAllByRole('button', { name: '重试' })[1]!)
expect(refreshModels).toHaveBeenCalledTimes(3)
update({ current: null, groups: [], failures: [], status: 'loading', error: null })
expect(screen.getByText('正在刷新模型列表…')).toBeTruthy()
update({ current: null, groups: [], failures: [], status: 'ready', error: null })
expect(screen.getByText('没有可用的模型。')).toBeTruthy()
})
it('closes after retrying a failed selection successfully', async () => {
const { retryModelOperation } = setup({
...ready,
status: 'error',
error: {
code: 'model-unavailable',
message: 'temporary failure',
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
},
})
retryModelOperation.mockResolvedValueOnce(true)
fireEvent.click(trigger())
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => { expect(screen.queryByRole('menu')).toBeNull() })
})
it('supports Arrow/Home/End/Escape navigation and restores focus to the trigger', async () => {
setup()
fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
await waitFor(() => {
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
})
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[1])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[0])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByRole('menu')).toBeNull()
expect(document.activeElement).toBe(trigger())
})
})
it('opens ArrowUp on the last option, disables rows while selecting, and dismisses outside', async () => {
const { update } = setup()
fireEvent.keyDown(trigger(), { key: 'ArrowUp' })
await waitFor(() => {
expect(document.activeElement).toBe(screen.getAllByRole('menuitemradio')[2])
})
update({ ...ready, status: 'selecting' })
expect(screen.getByRole('menu').getAttribute('aria-busy')).toBe('true')
expect(screen.getAllByRole('menuitemradio').every(row => (row as HTMLButtonElement).disabled)).toBe(true)
fireEvent.mouseDown(document.body)
expect(screen.queryByRole('menu')).toBeNull()
})
it('disables the trigger only when the session is removed', () => {
setup(ready, true)
expect(trigger().disabled).toBe(true)
})
})

View File

@@ -0,0 +1,28 @@
/** Host-half placeholder and package invariant companion. */
import { describe, expect, it, vi } from 'vitest'
import { apply as nodeApply } from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
describe('model-selector node half and invariant companion', () => {
it('keeps the host half as an intentional no-op', () => {
nodeApply(undefined as never)
expect(true).toBe(true)
})
it('registers the package-owned empty invariant installer', async () => {
const register = vi.fn().mockReturnValue(() => {})
const ctx = { invariants: { register } } as never
const dispose = await invariant.apply(ctx)
expect(invariant.name).toBe('client-ui-model-selector-invariant')
expect(invariant.inject).toEqual(['invariants'])
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-client-ui-model-selector',
expect.any(Function),
)
expect(() => {
(register.mock.calls[0]![1] as (inner: never) => void)(undefined as never)
}).not.toThrow()
expect(dispose).toBeTypeOf('function')
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-model-selector', ['lib/types/index.js', 'lib/types/invariant.js'])

View File

@@ -10,6 +10,8 @@ The layering/protocol decisions are recorded in the [GUI layering and RPC protoc
The mux stream projects the latest log-backed title as a validated `session/title` control frame after each attached-session subscription baseline and immediately after the corresponding live raw title event. This projection does not add titles to `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Session model routing is a session-domain contract. `session.history` returns the selected `modelTarget`, `session.models` returns that target with provider-grouped advisory model metadata and provider-local lookup failures, and `session.selectModel` replaces the target selected for the next prompt-assembly boundary. Catalog membership is not validation: a registered provider may accept an unlisted model, while an unregistered provider returns `model-unavailable`.
## Carrier layer (`/client` + root)
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.

View File

@@ -19,7 +19,10 @@ export interface ApiProxy {
}
// ---- Domain interfaces and payload entities ----
export type { HistoryEntry, SessionsApi, SessionSummary } from './sessions.ts'
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
SessionModels, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { ApprovalResponsePayload } from './approvals.ts'

View File

@@ -13,6 +13,8 @@ export interface RpcMethodMap {
'session.list': SessionsApi['list']
'session.create': SessionsApi['create']
'session.history': SessionsApi['history']
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.prompt': SessionsApi['prompt']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']

View File

@@ -35,6 +35,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -32,6 +32,7 @@ export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'model-unavailable': { provider: string; model: string }
'agent-busy': { reason: string }
'internal': {}
}

View File

@@ -9,7 +9,10 @@ import { z } from 'zod'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { HistoryEntry, SessionSummary } from './sessions.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget,
SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
@@ -61,6 +64,34 @@ export const sessionHistoryRequestSchema = z.object({
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
/** Complete provider/model target. */
export const modelTargetSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
}) satisfies z.ZodType<Wire<ModelTarget>>
/** One advisory model entry inside a provider group. */
export const modelCatalogModelSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
unlisted: z.literal(true).optional(),
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
/** One successfully loaded provider group. */
export const modelProviderGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
models: z.array(modelCatalogModelSchema),
}) satisfies z.ZodType<Wire<ModelProviderGroup>>
/** One provider-local catalog failure. */
export const modelCatalogFailureSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
message: z.string(),
}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
/**
* ToolEventView passthrough: lock only the `for` discriminant and the presence
* of a card-tagged `view` object. The view interior is a host-computed product
@@ -82,8 +113,33 @@ export const historyEntrySchema = z.object({
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
modelTarget: modelTargetSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** session.models request payload. */
export const sessionModelsRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.models'>>>
/** session.models response value. */
export const sessionModelsValueSchema = z.object({
current: modelTargetSchema,
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
/** session.selectModel request payload. */
export const sessionSelectModelRequestSchema = z.object({
sessionId: sessionIdSchema,
provider: z.string().min(1),
model: z.string().min(1),
}) satisfies z.ZodType<Wire<RequestPayload<'session.selectModel'>>>
/** session.selectModel response value. */
export const sessionSelectModelValueSchema = z.object({
selected: modelTargetSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })

View File

@@ -31,6 +31,56 @@ export interface HistoryEntry {
view?: ToolEventView
}
/** Complete provider/model route selected for one session. */
export interface ModelTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
}
/** One model displayed inside its provider group. */
export interface ModelCatalogModel {
/** Provider-owned model id. */
id: string
/** Provider-supplied display name. */
name: string
/** Optional provider-supplied description. */
description?: string
/** The current model was inserted because the advisory catalog omitted it. */
unlisted?: true
}
/** One provider and the models it advertised successfully. */
export interface ModelProviderGroup {
/** Provider route id used for requests. */
id: string
/** Provider display name. */
name: string
/** Models in provider-preferred order. */
models: ModelCatalogModel[]
}
/** A provider whose asynchronous catalog lookup failed. */
export interface ModelCatalogFailure {
/** Provider route id. */
id: string
/** Provider display name. */
name: string
/** Lookup failure diagnostic. */
message: string
}
/** Detached model-directory snapshot for one session. */
export interface SessionModels {
/** Target selected for the session's next assembled step. */
current: ModelTarget
/** Successfully loaded provider groups. */
groups: ModelProviderGroup[]
/** Provider-local failures; successful groups remain usable. */
failures: ModelCatalogFailure[]
}
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
@@ -62,7 +112,17 @@ export interface SessionsApi {
* rebuilds the surface from the events with the shared fold.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; modelTarget: ModelTarget }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete route for this session. The registered provider is
* validated, while model catalog membership remains advisory.
*/
selectModel(request: RpcRequest<{ sessionId: SessionId; provider: string; model: string }>):
Promise<RpcResponse<{ selected: ModelTarget }>>
/** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):

View File

@@ -19,7 +19,9 @@ import {
sessionCreateValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionSelectModelValueSchema,
} from '../api/sessions.schema.ts'
/**
@@ -42,6 +44,8 @@ export interface IApiClient {
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
@@ -64,6 +68,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.list': sessionListValueSchema,
'session.create': sessionCreateValueSchema,
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
@@ -245,6 +251,8 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('session.list', payload, signal),
create: (payload, signal) => this.callUnary('session.create', payload, signal),
history: (payload, signal) => this.callUnary('session.history', payload, signal),
models: (payload, signal) => this.callUnary('session.models', payload, signal),
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}

View File

@@ -19,7 +19,9 @@ import {
sessionCreateRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
@@ -41,6 +43,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },

View File

@@ -28,7 +28,19 @@ function scriptedApi(overrides: {
sessions: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { sessionId: sid('s-new') }),
history: r => ok(r, { events: [], hasMore: false }),
history: r => ok(r, {
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}),
models: r => ok(r, {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
}),
selectModel: r => ok(r, {
selected: { provider: r.payload.provider, model: r.payload.model },
}),
prompt: r => ok(r, { accepted: true as const }),
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,

View File

@@ -30,6 +30,28 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } },
}
},
async models(request) {
return {
rpcId: request.rpcId,
result: {
ok: true,
value: {
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [],
failures: [],
},
},
}
},
async selectModel(request) {
return {
rpcId: request.rpcId,
result: {
ok: true,
value: { selected: { provider: request.payload.provider, model: request.payload.model } },
},
}
},
async prompt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
@@ -78,6 +100,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
it('covers create/prompt/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.sessions.selectModel({
sessionId: 's' as never,
provider: 'deepseek',
model: 'deepseek-v4-flash',
})).result.ok).toBe(true)
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)

View File

@@ -8,8 +8,9 @@ import { z } from 'zod'
import {
contentBlockSchema, sessionCancelRequestSchema, sessionCancelValueSchema, sessionCreateRequestSchema,
sessionCreateValueSchema, sessionEventSchema, sessionHistoryRequestSchema, sessionHistoryValueSchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionPromptRequestSchema,
sessionPromptValueSchema, sessionSummarySchema,
sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema,
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
@@ -31,6 +32,11 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({
code: 'model-unavailable',
message: 'm',
details: { provider: 'p', model: 'm' },
}).code).toBe('model-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
@@ -99,7 +105,39 @@ describe('sessions domain schemas', () => {
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
expect(sessionHistoryValueSchema.parse({ events: [], hasMore: false }).hasMore).toBe(false)
expect(sessionHistoryValueSchema.parse({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}).hasMore).toBe(false)
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
groups: [{
id: 'deepseek',
name: 'DeepSeek',
models: [{
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
description: 'fast',
unlisted: true,
}],
}],
failures: [{ id: 'broken', name: 'Broken', message: 'offline' }],
}).groups[0]?.models[0]?.id).toBe('deepseek-v4-flash')
expect(sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: 'deepseek',
model: 'deepseek-v4-pro',
}).model).toBe('deepseek-v4-pro')
expect(sessionSelectModelValueSchema.parse({
selected: { provider: 'deepseek', model: 'deepseek-v4-pro' },
}).selected.model).toBe('deepseek-v4-pro')
expect(() => sessionSelectModelRequestSchema.parse({
sessionId: 's1',
provider: '',
model: 'm',
})).toThrow()
const prompt = sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'queue', content: [{ type: 'text', text: 'hi' }] })
expect(prompt.mode).toBe('queue')
expect(() => sessionPromptRequestSchema.parse({ sessionId: 's1', mode: 'inject', content: [] })).toThrow()

View File

@@ -20,6 +20,8 @@ Which plugins mount and with what defaults is decided only here — shells must
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session and every still-pending question with its original rpcId. Question responses, including blank per-item answers, are validated against the owning session and exact request before an atomic first-wins claim; answer, whole-request cancellation, owner abort, and provider disposal broadcast `question/resolved`. The host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
The Web front door installs the shared `installAgentLlmTarget` coupling for every created or resumed agent. A session starts from the latest logged `request/header` route when one exists, otherwise from the Host default. `session.models` discovers every registered provider concurrently, keeps successful groups when another provider fails, and inserts the current target as an unlisted row when its provider omits it. A selection changes the mutable session target immediately; prompt assembly snapshots it atomically with request routing, so a change during a running step first applies to the next assembled step.
## Model Experience
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled.

View File

@@ -6,13 +6,15 @@
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import type { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTargetRef, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget,
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
} from '@deepseek-ai/dsh-host-apiproxy/api'
import { questionResponsePayloadSchema } from '@deepseek-ai/dsh-host-apiproxy/api/questions.schema'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -280,11 +282,36 @@ class SessionNotFound extends Error {}
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
type WebLlmTargetRef = AgentLlmTargetRef & { current: ModelTarget }
const targets = new WeakMap<Agent, WebLlmTargetRef>()
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/** Install or return the session-local target that prompt assembly snapshots. */
function targetFor(agent: Agent): WebLlmTargetRef {
const installed = targets.get(agent)
if (installed !== undefined) return installed
const logged = agent.session.requestHeader()?.config
const target: WebLlmTargetRef = {
current: logged === undefined
? { provider: defaults.provider, model: defaults.model }
: { provider: logged.provider, model: logged.model },
assembled: undefined,
}
installAgentLlmTarget(agent.ctx, target)
targets.set(agent, target)
return target
}
/** Pre-publication setup used by both fresh and resumed Web agents. */
function installTarget(agentCtx: Context): void {
const agent = agentCtx.agent
if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
targetFor(agent)
}
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
@@ -363,7 +390,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
resume = (async () => {
try {
await assertServable(sessionId)
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
setup: installTarget,
})
return handle.agent
} finally {
resumes.delete(sessionId)
@@ -409,7 +440,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// one, the default project is the host-level default (the host process
// working directory unless boot overrides it).
const cwd = request.payload.cwd ?? defaults.cwd
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
const handle = await ctx.agents.create({
sessionId,
agentOptions,
meta: { cwd },
setup: installTarget,
})
return ok(request, { sessionId: handle.agent.id })
},
@@ -425,7 +461,71 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
return ok(request, { events: entries, hasMore: page.hasMore })
const current = targetFor(found.agent).current
return ok(request, { events: entries, hasMore: page.hasMore, modelTarget: { ...current } })
},
async models(request) {
const { sessionId } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const current = targetFor(found.agent).current
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
try {
const models = await ctx.llm.listModels(provider.id)
const group: ModelProviderGroup = {
id: provider.id,
name: provider.name,
models: models.map(model => ({
id: model.id,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
})),
}
return { kind: 'group' as const, group }
} catch (error: unknown) {
const failure: ModelCatalogFailure = {
id: provider.id,
name: provider.name,
message: error instanceof Error ? error.message : String(error),
}
return { kind: 'failure' as const, failure }
}
}))
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
const currentGroup = groups.find(group => group.id === current.provider)
if (
currentGroup !== undefined
&& !currentGroup.models.some(model => model.id === current.model)
) {
currentGroup.models.push({
id: current.model,
name: current.model,
unlisted: true,
})
}
return ok(request, {
current: { ...current },
groups: groups.filter(group => group.models.length > 0),
failures,
})
},
async selectModel(request) {
const { sessionId, provider, model } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
if (!ctx.llm.listProviders().some(entry => entry.id === provider)) {
return err(request, {
code: 'model-unavailable',
message: `provider "${provider}" is not registered`,
details: { provider, model },
})
}
const selected: ModelTarget = { provider, model }
targetFor(found.agent).current = selected
return ok(request, { selected: { ...selected } })
},
async prompt(request) {

View File

@@ -0,0 +1,165 @@
/**
* Web session model-directory and selection behavior: dynamic provider grouping,
* provider-local catalog failures, logged-target restoration, advisory unlisted
* models, and the prompt-assembly boundary for a running selection change.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmProviderInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`models-${String(nextRpc++)}`), payload }
}
class CatalogAdapter extends LlmAdapter {
constructor(
private readonly name: string,
private readonly models: readonly LlmModelInfo[] | Error,
) {
super()
}
override providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: this.name }
}
override listModels(): Promise<readonly LlmModelInfo[]> {
return this.models instanceof Error
? Promise.reject(this.models)
: Promise.resolve(this.models)
}
override async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
// Catalog tests never enter provider streaming.
}
}
async function harness(logged?: { provider: string; model: string }): Promise<{
ctx: Context
agent: Agent
sessionId: SessionId
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(LlmService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
]))
ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline')))
ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', []))
ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [
{ provider: 'duplicate', id: 'same', name: 'Same' },
{ provider: 'duplicate', id: 'same', name: 'Same Again' },
]))
const session = ctx.sessions.create()
if (logged !== undefined) {
session.append('request/header', { header: { config: logged }, reason: 'initial' })
}
const agent = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
ctx.agents.register(agent)
return { ctx, agent, sessionId: session.id }
}
function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false } }): T {
if (!response.result.ok) throw new Error('expected successful response')
return response.result.value
}
describe('Web session model selection', () => {
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
const { ctx, sessionId } = await harness({ provider: 'deepseek', model: 'private-preview' })
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp' })
const history = expectValue(await api.sessions.history(request({ sessionId })))
expect(history.modelTarget).toEqual({ provider: 'deepseek', model: 'private-preview' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({ provider: 'deepseek', model: 'private-preview' })
expect(catalog.groups).toEqual([{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
{ id: 'private-preview', name: 'private-preview', unlisted: true },
],
}])
expect(catalog.failures).toEqual([
{ id: 'broken', name: 'Broken Provider', message: 'catalog offline' },
{
id: 'duplicate',
name: 'Duplicate Provider',
message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"',
},
])
await ctx.fiber.dispose()
})
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
expect(expectValue(await api.sessions.history(request({ sessionId }))).modelTarget)
.toEqual({ provider: 'deepseek', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
const selected = expectValue(await api.sessions.selectModel(request({
sessionId,
provider: 'deepseek',
model: 'private-preview',
})))
expect(selected.selected).toEqual({ provider: 'deepseek', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek', model: 'private-preview' })
const rejected = await api.sessions.selectModel(request({
sessionId,
provider: 'missing',
model: 'model',
}))
expect(rejected.result).toEqual({
ok: false,
error: {
code: 'model-unavailable',
message: 'provider "missing" is not registered',
details: { provider: 'missing', model: 'model' },
},
})
expect(expectValue(await api.sessions.history(request({ sessionId }))).modelTarget)
.toEqual({ provider: 'deepseek', model: 'private-preview' })
await ctx.fiber.dispose()
})
})

View File

@@ -20,13 +20,13 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value
models: # optional; defaults to V4 Flash and V4 Pro
- id: deepseek-v4-flash
name: DeepSeek V4 Flash
name: DeepSeek-V4-Flash
- id: private-reasoner
description: Company-hosted reasoning model
contextWindow: 64000
```
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 256,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.

View File

@@ -21,8 +21,8 @@ export const name = 'llm-deepseek'
export const inject = ['llm']
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{ id: 'deepseek-v4-flash', contextWindow: 128_000 },
{ id: 'deepseek-v4-pro', contextWindow: 128_000 },
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 },
]
/**

View File

@@ -528,11 +528,11 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
])
await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash'))
.resolves.toEqual({ contextWindow: 128_000 })
.resolves.toEqual({ contextWindow: 256_000 })
})
it('uses the default model catalog when apply is called directly', async () => {
@@ -540,8 +540,8 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
])
})