From c132cbdb6a63161c88400b0403d046e89fcba09d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:28:45 +0800 Subject: [PATCH] Adapt session model selection to the slash/input/session architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/cli/cordis.yml | 4 + apps/cli/package.json | 1 + packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 40 ++++- .../client/connection/src/client/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 32 ++-- .../client/connection/tests/fixture.spec.ts | 2 - packages/client/runtime/tests/fake-api.ts | 33 ++-- packages/client/runtime/tests/session.spec.ts | 159 ----------------- packages/client/ui-model/package.json | 61 +++++++ packages/client/ui-model/src/client/index.ts | Bin 0 -> 3506 bytes packages/client/ui-model/src/css-modules.d.ts | 6 + packages/client/ui-model/src/index.ts | 9 + packages/client/ui-model/src/invariant.ts | 31 ++++ packages/client/ui-model/tsconfig.json | 30 ++++ packages/client/ui-model/tsdown.config.ts | 3 + packages/host/apiproxy/src/api-proxy.ts | 105 +++++++++++- packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 1 - packages/host/apiproxy/src/api/sessions.ts | 2 +- packages/host/apiproxy/src/index.ts | 2 +- .../apiproxy/tests/api-proxy-models.spec.ts | 162 ++++++++++++++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 44 ++++- pnpm-lock.yaml | 24 +++ tsconfig.base.json | 1 + tsconfig.client.json | 1 + 27 files changed, 564 insertions(+), 193 deletions(-) create mode 100644 packages/client/ui-model/package.json create mode 100644 packages/client/ui-model/src/client/index.ts create mode 100644 packages/client/ui-model/src/css-modules.d.ts create mode 100644 packages/client/ui-model/src/index.ts create mode 100644 packages/client/ui-model/src/invariant.ts create mode 100644 packages/client/ui-model/tsconfig.json create mode 100644 packages/client/ui-model/tsdown.config.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-models.spec.ts diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..6d72bcae58 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -300,6 +300,10 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' +# Model selection: the /model popupSelect over session.models / selectModel. +- id: ui-model + name: '@deepseek-ai/dsh-client-ui-model' + - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index e0a3a51c94..44920d6baf 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -29,6 +29,7 @@ "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index edc5b2e25d..181b385077 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -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 { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index eaab0a43f9..27b7269a4d 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -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([[sid('fx-alpha'), buildAlphaLog()]]) + const modelTargets = new Map(sessions.map(session => [ + session.sessionId, + { provider: 'deepseek', model: 'deepseek-v4-flash' }, + ])) const nextTurn = new Map([[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) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d4505eb659..e255664eb1 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -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, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..580224060b 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -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> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ events: [], hasMore: false })) + => Promise> = + () => Promise.resolve(ok({ + events: [], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-chat' }, + })) + onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ + current: { provider: 'deepseek', model: 'deepseek-chat' }, + groups: [], + failures: [], + })) + onSelectModel: (payload: ModelTarget & { sessionId: SessionId }) + => Promise> = + payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = @@ -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> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index fc6c0a1cef..8dbb466213 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -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, diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..6e0f0df2ef 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -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> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => 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> = - () => Promise.resolve(ok({ events: [], hasMore: false })) + => Promise> = + () => Promise.resolve(ok({ events: [], hasMore: false, modelTarget: this.defaultModel })) + onModels: (payload: unknown) => Promise> = () => 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> = + payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = @@ -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> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> = () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index a3bd647117..9a7f9e5ad6 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -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>>() - 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>>() - 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() diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json new file mode 100644 index 0000000000..67eafd66cc --- /dev/null +++ b/packages/client/ui-model/package.json @@ -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" + ] +} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e83a05c7a321d9e4eb736e66f6a308f6cf25c48a GIT binary patch literal 3506 zcmbtXPm3c(6!)A@@fL>dWK25uNOWOQ+{3cs9@l|Xx+_UJm8!O?Ix`^~@S=!EQ3Uni z!M%781i_1Y@uMt^?#-{@d#|cHo!MDNFfcp4>G%Kr-tWB`pPu&M6du}AYVcCaA}DL1 z()CiAAq?d&%8l~YZJ|U1MWCntESb*Pw4V)z zoitD{NVXML_`F;}D;{&@yl2p-7+2N@@J3XAWrGJ5Km-oT$jgx8Syy2syqD{_-oo70 zrW9_A9xVuPwK2*pA=J*mxs(;z@Q}nY{2<&?hCxoYK4@~&wh$T$5rno&SO}$SCq4Mu zEkq$v8IsOdlBSd;850X^3Wr*=C~(32TX3&8w7EOrwVmJ4Hm7oUFx+TN3tJGgI+iNJavW6*L4k0Tzq{2unOaCQyZy^^XX<@rdcap_m1LMv&)2r-rP$MKu& zx&xL&HvwC9t2t~`P;2=*TXwz1C?89=QH3O%P85Mcrr~(Rh`rTCtuSb|(|YNRPfsDK zs}kp=l*I(Bk+9GRv{*=u^98;VI1>%}Q!{fo9rqTsiSro;=Rw(Tq$bz{hu|2-5o`WC zXdYnK1Ds#R=ImtGsO{f4+411)XT5!oLwpaT3DPi}4s)hVuqj4{U!NfcTFj9*2tkn( zN*NHrn4GTHh=m(s_Kz0**6idM2pDa*%1_QXYV1B1g}lDR zL>`A>z_yNLIdMQ)mPiEB$YHh6V_wyM)u-CUk1DZc3<0ctR8wWDd9KRAu$AP>Oky{3 zBi8cpDi*1ER~u?N(!+E*h2UzO!}jwIV6UPZGO>q0e}}(*`1$IKZ?8Uk_V@R{Ts{A0 z))8+^VmAt>B2w?5Rir7oPzHV6WH2Eh?`Z93FiBN84jm^!ZRDg=-J*65XEbjR#c4E^ zfxCBa5RG-GbomME*3@P{;B5BzFa0FEQX?94eu!L#6AYUs;;tYzX++@KAQf!$OpKpSdS( zOxy|O_OK|~f7+8rA-0RrSsS2@kXOUKN;t8GB3!}*TO5;pzEb7zrrSYUzs{Q*qh#<*BCZ|;jUa0I;xWDJrlLZpLHd=trniySdU zL=aXAdut8$)eAZ=lB(DsGtnZQK=ewp7OmE5lGp(NH!RM0*12kuJ~Y?L1Z%T31@u!H zas8{M5t>suP(@R0+~&2Wr8EU`A@Hhbs-Fm_-}d7HI!-V1^!q^x`_?{|E`HPk`gfw; z+@23GWgHPO@&ewaZGHVF;;TZU@snLr;-#L`3fjN&?Ha)$mU2Ifvq&CeDl~?eM5I5W z@nmcxbS)3U + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-model/src/index.ts b/packages/client/ui-model/src/index.ts new file mode 100644 index 0000000000..83daadbf12 --- /dev/null +++ b/packages/client/ui-model/src/index.ts @@ -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 {} diff --git a/packages/client/ui-model/src/invariant.ts b/packages/client/ui-model/src/invariant.ts new file mode 100644 index 0000000000..baac6dcc70 --- /dev/null +++ b/packages/client/ui-model/src/invariant.ts @@ -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 */ diff --git a/packages/client/ui-model/tsconfig.json b/packages/client/ui-model/tsconfig.json new file mode 100644 index 0000000000..5cefc3e6de --- /dev/null +++ b/packages/client/ui-model/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/client/ui-model/tsdown.config.ts b/packages/client/ui-model/tsdown.config.ts new file mode 100644 index 0000000000..79905da095 --- /dev/null +++ b/packages/client/ui-model/tsdown.config.ts @@ -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']) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..611a1f7384 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { join } from 'node:path' import type { Context } from 'cordis' -import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent' +import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentLlmTargetRef, AgentMessage, AgentMessageId, 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' @@ -20,7 +21,8 @@ import { // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' import type { - ApiProxy, HistoryEntry, HostFrame, MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, + ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelTarget, + MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. @@ -348,6 +350,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie */ 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() /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */ const resumes = new Map>() /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */ @@ -357,6 +361,34 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingQuestions = new Map() const muxQueues = new Set>>() + /** + * Install or return the session-local target that prompt assembly snapshots. + * Seed order: latest logged request/header, else the host default routing. + * There is no create-time per-session override tier on this wire — if one + * returns (a create-options contribution), it must fold in between the two. + */ + 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) @@ -470,7 +502,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) @@ -645,6 +681,69 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { events: entries, hasMore: page.hasMore }) }, + 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) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index d83ae2ce98..d12d643e3f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -41,6 +41,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), + 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 diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index ad06c42fbe..652331ebd5 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -38,6 +38,7 @@ export interface RpcErrorDetailsMap { 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } + 'model-unavailable': { provider: string; model: string } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a7f451bf55..69d1ab3fa3 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -128,7 +128,6 @@ export const historyEntrySchema = z.object({ export const sessionHistoryValueSchema = z.object({ events: z.array(historyEntrySchema), hasMore: z.boolean(), - modelTarget: modelTargetSchema, }) satisfies z.ZodType>> /** session.models request payload. */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index afd22d9739..c12e9b317f 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -129,7 +129,7 @@ export interface SessionsApi { * rebuilds the surface from the events with the shared fold. */ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): - Promise> + Promise> /** Reads a fresh advisory model directory for this session. Provider lookups run independently. */ models(request: RpcRequest<{ sessionId: SessionId }>): Promise> diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 8a63c3de32..e7ef6c7332 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -45,7 +45,7 @@ export interface Config { * project directory and the fallback parent for name-created Workspaces. */ export class ApiProxyService extends Service implements ApiProxy { - static inject = ['agents', 'sessions', 'tools', 'userInteraction', 'workspace'] + static inject = ['agents', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace'] static Config: z = z.object({ provider: z.string().required(), diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts new file mode 100644 index 0000000000..96efe60a9d --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -0,0 +1,162 @@ +/** + * 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

(payload: P): RpcRequest

{ + 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 { + return this.models instanceof Error + ? Promise.reject(this.models) + : Promise.resolve(this.models) + } + + override async *stream(_options: GenerateOptions): AsyncIterable { + // 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(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', workspaceRoot: '/tmp' }) + + 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', workspaceRoot: '/tmp' }) + const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + const signal = new AbortController().signal + + expect(expectValue(await api.sessions.models(request({ sessionId }))).current) + .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.models(request({ sessionId }))).current) + .toEqual({ provider: 'deepseek', model: 'private-preview' }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 02ca8dec22..aa08f8a0d2 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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 { @@ -55,6 +56,11 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') + 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') }) @@ -128,7 +134,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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..7031a77617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../packages/client/ui-layout + '@deepseek-ai/dsh-client-ui-model': + specifier: workspace:^ + version: link:../../packages/client/ui-model '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models @@ -959,6 +962,27 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-model: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../ui-command + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-models: devDependencies: '@deepseek-ai/dsh-client-locale': diff --git a/tsconfig.base.json b/tsconfig.base.json index fcfe6f6e90..271a91567b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -113,6 +113,7 @@ "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], + "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 0da6e76918..caa3000a9f 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -39,6 +39,7 @@ { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" },