Adapt session model selection to the slash/input/session architecture
- host trio kept on the merged api-proxy: session.models (provider-grouped advisory directory), session.selectModel (validated provider, advisory model), installAgentLlmTarget threaded through create/resume/ensureSession; the gateway declares the llm inject it reads - history no longer piggybacks modelTarget: the current target travels on session.models alone (the /model popup is the sole consumer) - new @deepseek-ai/dsh-client-ui-model plugin: /model popupSelect over the wire — options load the directory (group label in the detail column, provider-local failures listed inline), onSelect routes selectModel; failures ride the popup shell's error/retry surface - ModelSelector package, conversation.composer.control slot, and the Session-side modelSelection state machine are removed: model selection belongs to the /model popup; the named conversation.input.model seat stays empty until a control-seat entry is designed for it
This commit is contained in:
@@ -10,6 +10,7 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
export type {
|
||||
|
||||
@@ -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, WorkspaceId, WorkspaceView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
@@ -354,6 +354,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: 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
|
||||
@@ -596,6 +600,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
@@ -624,7 +629,32 @@ export function createFixtureApi(options: FixtureOptions = {}): 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 })
|
||||
},
|
||||
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
|
||||
@@ -660,7 +690,9 @@ export function createFixtureApi(options: FixtureOptions = {}): 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 })
|
||||
},
|
||||
@@ -908,6 +940,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)
|
||||
|
||||
@@ -15,6 +15,7 @@ export type {
|
||||
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelTarget, SessionModels,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SkillEntry,
|
||||
CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
|
||||
@@ -45,9 +45,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 }>> =
|
||||
@@ -64,6 +76,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)),
|
||||
}
|
||||
@@ -88,12 +103,9 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program catalogs and skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
|
||||
@@ -71,7 +71,6 @@ describe('createFixtureApi', () => {
|
||||
expect(empty.result.value).toEqual({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,7 +92,6 @@ describe('createFixtureApi', () => {
|
||||
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,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// data source on a real clock; behavior tests need per-case responses and
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry,
|
||||
ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -61,10 +61,23 @@ export class FakeApiClient implements IApiClient {
|
||||
// Programmable slots (defaults answer OK-empty); reassign per case.
|
||||
onList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false }))
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
() => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel }))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: this.defaultModel,
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
|
||||
}],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: { provider: string; model: string }) =>
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
@@ -81,6 +94,9 @@ export class FakeApiClient implements IApiClient {
|
||||
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: { provider: string; model: string }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)),
|
||||
cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)),
|
||||
}
|
||||
@@ -110,12 +126,9 @@ export class FakeApiClient implements IApiClient {
|
||||
// Payloads stay `unknown` (lint-lane note above); response rows are the real
|
||||
// wire shapes so cases can program requires-bearing catalogs and dual-address
|
||||
// skill lists without casts.
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>>
|
||||
= () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
|
||||
= () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
onCommandList: (payload: unknown) => Promise<RpcResponse<{ commands: CommandDescriptor[] }>> = () => Promise.resolve(ok({ commands: [] }))
|
||||
onCommandExecute: (payload: unknown) => Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>> = () => Promise.resolve(ok({ matched: false }))
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>> = () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
|
||||
@@ -91,164 +91,6 @@ describe('open', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('model selection', () => {
|
||||
it('restores the current target from history, then refreshes grouped models with partial failures', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse([])
|
||||
api.onModels = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
|
||||
}],
|
||||
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
|
||||
}))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().modelSelection).toEqual({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [],
|
||||
failures: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
})
|
||||
|
||||
const refreshing = session.refreshModels()
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('loading')
|
||||
await refreshing
|
||||
expect(session.getSnapshot().modelSelection).toEqual({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }],
|
||||
}],
|
||||
failures: [{ id: 'offline', name: 'Offline', message: 'catalog down' }],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the previous target and directory when selection fails, then accepts a retry', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
await session.refreshModels()
|
||||
const before = session.getSnapshot().modelSelection
|
||||
api.onSelectModel = () => Promise.resolve(err({
|
||||
code: 'model-unavailable',
|
||||
message: 'gone',
|
||||
details: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
}))
|
||||
|
||||
const failed = session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('selecting')
|
||||
await failed
|
||||
const errored = session.getSnapshot().modelSelection
|
||||
expect(errored.current).toBe(before.current)
|
||||
expect(errored.groups).toBe(before.groups)
|
||||
expect(errored).toMatchObject({ status: 'error', error: { code: 'model-unavailable' } })
|
||||
|
||||
api.onSelectModel = payload => Promise.resolve(ok({
|
||||
selected: { provider: payload.provider, model: payload.model },
|
||||
}))
|
||||
await session.retryModelOperation()
|
||||
expect(api.callsOf('session.selectModel').at(-1)).toEqual({
|
||||
sessionId: SID,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-v4-pro',
|
||||
})
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
status: 'ready',
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('drops stale directory responses after a newer selection operation wins', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
const stale = deferred<Awaited<ReturnType<FakeApiClient['onModels']>>>()
|
||||
api.onModels = () => stale.promise
|
||||
const refreshing = session.refreshModels()
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
const selected = session.getSnapshot().modelSelection
|
||||
|
||||
stale.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'stale' },
|
||||
groups: [{ id: 'deepseek', name: 'Old', models: [{ id: 'stale', name: 'Stale' }] }],
|
||||
failures: [],
|
||||
}))
|
||||
await refreshing
|
||||
expect(session.getSnapshot().modelSelection).toBe(selected)
|
||||
expect(session.getSnapshot().modelSelection.current)
|
||||
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
})
|
||||
|
||||
it('does not let an older history response overwrite a newer selected target', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const history = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
api.onHistory = () => history.promise
|
||||
const opening = session.open()
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
history.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await opening
|
||||
expect(session.getSnapshot().modelSelection.current)
|
||||
.toEqual({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
})
|
||||
|
||||
it('folds directory and selection transport failures without discarding usable state', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
await session.refreshModels()
|
||||
const groups = session.getSnapshot().modelSelection.groups
|
||||
api.onModels = () => Promise.reject(new Error('directory transport down'))
|
||||
await session.refreshModels()
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
groups,
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'directory transport down' },
|
||||
})
|
||||
api.onModels = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups: [...groups],
|
||||
failures: [],
|
||||
}))
|
||||
await session.retryModelOperation()
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('ready')
|
||||
|
||||
api.onSelectModel = () => Promise.reject(new Error('selection transport down'))
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
groups,
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'selection transport down' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reconciles a failed selection from authoritative history on reconnect', async () => {
|
||||
const { api, session } = makeSession()
|
||||
await session.open()
|
||||
api.onSelectModel = () => Promise.reject(new Error('lost response'))
|
||||
await session.selectModel({ provider: 'deepseek', model: 'deepseek-v4-pro' })
|
||||
expect(session.getSnapshot().modelSelection.status).toBe('error')
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
}))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot().modelSelection).toMatchObject({
|
||||
current: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||||
status: 'idle',
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('live event path', () => {
|
||||
async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) {
|
||||
@@ -949,7 +791,6 @@ describe('reference stability (the memo contract)', () => {
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.runningCalls).toBe(before.runningCalls)
|
||||
expect(after.pending).toBe(before.pending)
|
||||
expect(after.modelSelection).toBe(before.modelSelection)
|
||||
// And a mutation on the tracked domain swaps that array.
|
||||
feed(ev.toolResult(10, 1, 'c1', 'ECHO'))
|
||||
const resolved = session.getSnapshot()
|
||||
|
||||
61
packages/client/ui-model/package.json
Normal file
61
packages/client/ui-model/package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-model",
|
||||
"description": "Model selection: the /model popupSelect over session.models / session.selectModel",
|
||||
"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-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
BIN
packages/client/ui-model/src/client/index.ts
Normal file
BIN
packages/client/ui-model/src/client/index.ts
Normal file
Binary file not shown.
6
packages/client/ui-model/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-model/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-model/src/index.ts
Normal file
9
packages/client/ui-model/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Model selection plugin, node half. Pure UI plugin: the empty apply exists
|
||||
* so the plugin appears in the host cordis.yml / Loader; the browser half
|
||||
* ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-model/src/invariant.ts
Normal file
31
packages/client/ui-model/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model`.
|
||||
* @module @deepseek-ai/dsh-client-ui-model/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'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-model-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single command contribution registration whose disposal is
|
||||
* proven by the HMR-safety spec — it emits no cordis events and owns no
|
||||
* cross-plugin mutable state.
|
||||
*/
|
||||
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 */
|
||||
30
packages/client/ui-model/tsconfig.json
Normal file
30
packages/client/ui-model/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-command"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-model/tsdown.config.ts
Normal file
3
packages/client/ui-model/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-model', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user