Merge branch 'worktree/ci-native-windows-coverage-20260808' into worktree/ci-native-windows-multicore-20260809
This commit is contained in:
@@ -12,7 +12,7 @@ export type {
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ModelProviderGroup, ModelSelection, 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'
|
||||
@@ -1347,7 +1347,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
{ 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 => [
|
||||
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
@@ -1989,7 +1989,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
@@ -2098,7 +2098,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
current: modelSelections.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
// The fixture's routes all serve; a surface exercising the blocked
|
||||
// posture drives it through its own stub.
|
||||
@@ -2107,14 +2107,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: (request) => {
|
||||
const selected: ModelTarget = {
|
||||
const selected: ModelSelection = {
|
||||
provider: request.payload.provider,
|
||||
model: request.payload.model,
|
||||
...request.payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: request.payload.reasoningEffort },
|
||||
}
|
||||
modelTargets.set(request.payload.sessionId, selected)
|
||||
modelSelections.set(request.payload.sessionId, selected)
|
||||
return ok(request, { selected })
|
||||
},
|
||||
prompt: (request) => {
|
||||
@@ -2153,11 +2153,11 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== target.model) {
|
||||
const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== selection.model) {
|
||||
append(id, {
|
||||
type: 'request/context',
|
||||
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
|
||||
data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 },
|
||||
})
|
||||
}
|
||||
startReply(
|
||||
@@ -2167,9 +2167,9 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
? MARKDOWN_FIXTURE
|
||||
: userText === 'report model'
|
||||
? (() => {
|
||||
const target = modelTargets.get(id)
|
||||
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
|
||||
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
|
||||
const selection = modelSelections.get(id)
|
||||
return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}`
|
||||
+ (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`)
|
||||
})()
|
||||
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
@@ -50,11 +50,11 @@ export class FakeApiClient implements IApiClient {
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelSelection: ModelSelection }>> =
|
||||
() => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
@@ -63,8 +63,8 @@ export class FakeApiClient implements IApiClient {
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
onSelectModel: (payload: ModelSelection & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
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 }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
@@ -105,7 +105,7 @@ export class FakeApiClient implements IApiClient {
|
||||
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 }) =>
|
||||
selectModel: (payload: ModelSelection & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-alpha')
|
||||
const catalog = await api.sessions.models(req({ sessionId }))
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: a9b604974595b1b7856f74b72d36093491ec1bd1
|
||||
README.zh.md: 41c81532667f445ce1c52e1b84185ef503e82141
|
||||
README.md: c2ce461439a89aa4fe2d59ebf92d14539479dac8
|
||||
README.zh.md: bca93b80989152eccccaf13c0f31435c91e7393c
|
||||
|
||||
@@ -62,7 +62,7 @@ The Session object validates plugin-owned, provider-routed `llm/retry` payloads
|
||||
|
||||
## 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.
|
||||
Each resident `Session` owns a `modelSelection` snapshot containing the current `ModelSelection`, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current selection, opening a selector refreshes the directory, and selection failures preserve the last selection 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 selection reported by the Host without replacing unchanged selection substructure.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -70,7 +70,7 @@ None, as the session object layer selects the provider/model route used by a lat
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
|
||||
Changing the model selection can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段约定,验
|
||||
|
||||
## 会话模型选择
|
||||
|
||||
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。
|
||||
每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前 `ModelSelection`、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前选择,打开选择器会刷新目录;选择失败会保留上一个选择和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的选择,同时不替换未变化的选择子结构。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -70,7 +70,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段约定,验
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
|
||||
更改模型选择可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -64,7 +64,7 @@ export class FakeApiClient implements IApiClient {
|
||||
onSearch: (payload: unknown) => Promise<RpcResponse<{ items: SessionSearchItem[]; hasMore: boolean }>> =
|
||||
() => Promise.resolve(ok({ items: [], hasMore: false }))
|
||||
onCreate: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
|
||||
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
readonly defaultModel: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
@@ -82,7 +82,7 @@ export class FakeApiClient implements IApiClient {
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: { provider: string; model: string }) =>
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
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 }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
@@ -842,7 +842,7 @@ describe('connected generation', () => {
|
||||
api.onHistory = () => Promise.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
const openedSession = manager.get(S1)
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('open', () => {
|
||||
gate.resolve(ok({
|
||||
events: entries(page) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await opening
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
@@ -631,7 +631,7 @@ describe('paging', () => {
|
||||
gate.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, 'a', 'b')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await Promise.all([first, second])
|
||||
expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two
|
||||
@@ -1018,7 +1018,7 @@ describe('remaining branches', () => {
|
||||
stale.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '代')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'stale' },
|
||||
modelSelection: { provider: 'deepseek-official', 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
|
||||
@@ -1041,7 +1041,7 @@ describe('remaining branches', () => {
|
||||
secondPull.resolve(ok({
|
||||
events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'stale' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'stale' },
|
||||
}))
|
||||
await Promise.all([opening, resynced])
|
||||
expect(session.getSnapshot().openState).toBe('open')
|
||||
@@ -1059,7 +1059,7 @@ describe('remaining branches', () => {
|
||||
repairPull.resolve(ok({
|
||||
events: entries(plainTurn(0, 0, '旧', '页')) as never[],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'stale' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'stale' },
|
||||
})) // repair result: stale, dropped
|
||||
await resynced
|
||||
expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9])
|
||||
@@ -1104,7 +1104,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-official', model: 'deepseek-v4-flash' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
|
||||
README.md: 5a6f998476629566d35af32efa5d8bc5072a872b
|
||||
README.zh.md: 55ea296370ffa986c9b11b41f83ef83b12236e88
|
||||
README.md: b37f807944850fb34331cd8d78b4a2ccf157f21c
|
||||
README.zh.md: 906096bed06af26517e215b2d1e1da4b62e58095
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Model selection plugin, browser half: TWO entries over ONE per-session directory owned by `ModelService` (`ctx.models`). For ordinary sessions, the `/model` popupSelect contribution (registered through `ctx.command`) and the composer's named `conversation.input.model` seat both load the session's advisory directory through `session.models` and submit through `session.selectModel` via the same `ModelDirectory` instance. The compact composer trigger opens a two-level Model/Effort menu: models stay provider-grouped, while the selected exact model supplies its adapter-owned effort names, descriptions, and default. `/model` applies the selected model's default effort, and the composer can then choose any advertised effort.
|
||||
|
||||
The Host-reported provider/model/reasoning target is the single selection fact, but it is echoed only when the exact route remains in the advertised groups; removing that catalog row leaves the routable target intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored target before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior target and directory.
|
||||
The Host-reported provider/model/reasoning `ModelSelection` is the single selection fact, but it is echoed only when the exact provider/model pair remains in the advertised groups; an absent catalog row leaves the routable selection intact while the trigger prompts `Select model`, no stale row is synthesized, and no Effort row is shown until the user picks an advertised model. Directory loads and selections share a generation counter so an older response never overwrites a newer one; a connection reset drops every resident projection and repulls the Host-restored selection before display. Provider-local metadata failures list inline while usable groups stay selectable, and selection failures retain the prior selection and directory.
|
||||
|
||||
When the Host reports that no adapter serves the session's route (`session.models.routable`), this plugin raises a composer block through `ctx.conversation.blocks` and the input goes inert with this plugin's own copy; recovering clears it without a reload. It follows `routable` and nothing else: a `null` — before the first load, or after one failed — never blocks, or a slow Host would lock a working composer, and catalog membership never blocks either, because a route serving a model it stopped advertising is missing from the groups yet perfectly usable. The trigger's own `Select model` fallback still covers that case, which is display, not a gate.
|
||||
|
||||
@@ -14,7 +14,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`), `ModelServic
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the provider/model/reasoning target that the Host snapshots at the next prompt-assembly boundary, so the following request uses the chosen route and effort while a running step keeps its assembled target; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content.
|
||||
Indirectly, through the `session.selectModel` RPC available to ordinary sessions, both entries submit the complete `ModelSelection` that the Host snapshots at the next prompt-assembly boundary, so the following request uses the selected provider, model, and effort while a running step keeps its assembled selection; the selection becomes durable only when the existing request header records a request that consumes it, and menu interaction adds no prompt content.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -22,6 +22,6 @@ Switching the route can reduce or invalidate provider-side cache reuse for subse
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-retargeting contract.
|
||||
- **No create-time or addressed-subagent selection** — both entries require an existing ordinary session's Agent; there is no draft-phase model choice to fold into session creation, and subagent continuation deliberately exposes no independent model-selection contract.
|
||||
- **Directory names are presentation-only** — selection and persistence use provider/model/effort ids; a provider whose catalog or exact-model metadata lookup fails lists as an unselectable failure row until reload.
|
||||
- **No arbitrary effort input** — the composer offers only the exact model's adapter-advertised levels; an adapter without reasoning metadata leaves the Effort row absent.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
模型选择插件(浏览器侧):**两个入口共用一份会话级目录**,由 `ModelService`(`ctx.models`)持有。对于普通会话,`/model` popupSelect 贡献项(经 `ctx.command` 注册)与 composer 的具名 `conversation.input.model` slot 都通过同一个 `ModelDirectory` 实例,经 `session.models` 加载会话的建议目录,并经 `session.selectModel` 提交。紧凑型 composer 触发器会打开两级 Model/Effort 菜单:模型仍按提供方分组,所选具体模型则提供由其适配器持有的推理强度名称、说明和默认值。`/model` 应用所选模型的默认推理强度,composer 随后可以选择任一已公布的推理强度。
|
||||
|
||||
Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择事实,但只有当该精确路由仍在已公布分组中时才会回显;删除该目录行会保留仍可路由的目标,但触发器会提示 `Select model`,系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的目标。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的目标和目录。
|
||||
Host 报告的 `ModelSelection` 是唯一的选择事实,其中包含提供方、模型与推理(reasoning)强度;但只有当该提供方/模型对仍在已公布分组中时才会回显。目录行缺席时,可路由的选择保持不变,但触发器会提示 `Select model`;系统不会合成陈旧行,且在用户选择已公布的模型之前不会显示 Effort 行。目录加载与选择共享一个代次计数器,旧响应不会覆盖新结果;连接重置会丢弃所有常驻目录投影,并在显示前重新拉取 Host 恢复的选择。各提供方的元数据获取失败会内联列出,同时可用分组仍可选择;选择失败会保留先前的选择和目录。
|
||||
|
||||
当宿主报告没有适配器服务该会话的路由(`session.models.routable`)时,本插件经 `ctx.conversation.blocks` 注册一个 composer 阻塞块,输入框随之停用并显示本插件自己的文案;恢复后无需重新加载即自动清除。它只跟随 `routable`:`null`(首次加载之前,或加载失败之后)绝不阻断,否则一个慢的宿主就会锁死一个本来可用的 composer;目录成员关系同样不阻断,因为一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用。触发器自己的 `Select model` 回退仍然覆盖那种情形——那是显示,不是闸门。
|
||||
|
||||
@@ -14,7 +14,7 @@ Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交提供方/模型/推理强度目标;Host 会在下一次提示词组装边界对该目标进行快照,因此后续请求采用所选路由和推理强度,而运行中的步骤保留已组装目标。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
间接影响。两个入口都通过仅供普通会话使用的 `session.selectModel` RPC 提交完整的 `ModelSelection`;Host 会在下一次提示词组装边界对其进行快照,因此后续请求采用所选提供方、模型与推理强度,而运行中的步骤保留已组装选择。只有当现有请求头记录一次实际采用该选择的请求后,选择才会持久化;菜单交互不会添加提示词内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -22,6 +22,6 @@ Host 报告的提供方/模型/推理(reasoning)目标是唯一的选择
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立更改模型目标的约定。
|
||||
- **无创建期或已寻址 subagent 选择**——两个入口都要求既有普通会话的 agent;没有可纳入会话创建的草稿阶段模型选择,subagent 继续执行也有意不公开独立的模型选择约定。
|
||||
- **目录名仅供呈现**——选择与持久化使用提供方/模型/推理强度 id;目录查询或确切模型元数据查询失败的提供方以不可选失败行列出,重新加载前保持原样。
|
||||
- **不能任意输入推理强度**——composer 仅提供确切模型由适配器公布的推理强度;适配器没有推理元数据时不显示 Effort 行。
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type KeyboardEvent, type FocusEvent,
|
||||
} from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -58,17 +58,17 @@ export function ModelSelect(
|
||||
group.models.map(model => ({
|
||||
group,
|
||||
model,
|
||||
target: {
|
||||
selection: {
|
||||
provider: group.id,
|
||||
model: model.id,
|
||||
...model.reasoning?.defaultEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: model.reasoning.defaultEffort },
|
||||
} satisfies ModelTarget,
|
||||
} satisfies ModelSelection,
|
||||
}))), [state.groups])
|
||||
const selectedIndex = state.current === null
|
||||
? -1
|
||||
: choices.findIndex(c => c.target.provider === state.current?.provider && c.target.model === state.current.model)
|
||||
: choices.findIndex(c => c.selection.provider === state.current?.provider && c.selection.model === state.current.model)
|
||||
const currentChoice = choices[selectedIndex]
|
||||
const reasoning = currentChoice?.model.reasoning
|
||||
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
|
||||
@@ -148,12 +148,12 @@ export function ModelSelect(
|
||||
close()
|
||||
}
|
||||
|
||||
const choose = (target: ModelTarget): void => {
|
||||
if (state.current?.provider === target.provider && state.current.model === target.model) {
|
||||
const choose = (selection: ModelSelection): void => {
|
||||
if (state.current?.provider === selection.provider && state.current.model === selection.model) {
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
void select(target).then((accepted) => {
|
||||
void select(selection).then((accepted) => {
|
||||
if (accepted && rootRef.current !== null) close(true)
|
||||
})
|
||||
}
|
||||
@@ -164,12 +164,12 @@ export function ModelSelect(
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
const target: ModelTarget = {
|
||||
const selection: ModelSelection = {
|
||||
provider: state.current.provider,
|
||||
model: state.current.model,
|
||||
...effort === undefined ? {} : { reasoningEffort: effort },
|
||||
}
|
||||
void select(target).then((accepted) => {
|
||||
void select(selection).then((accepted) => {
|
||||
if (accepted && rootRef.current !== null) close(true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
* either entry is what the other shows next.
|
||||
*/
|
||||
import type {
|
||||
IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelTarget, SessionId, SessionModels,
|
||||
IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelSelection, SessionId, SessionModels,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Directory snapshot both entries render from. */
|
||||
export interface ModelDirectoryState {
|
||||
/** Target the host reports for the next assembled step; null before the first load. */
|
||||
current: ModelTarget | null
|
||||
/** Model selection the host reports for the next assembled step; null before the first load. */
|
||||
current: ModelSelection | null
|
||||
/**
|
||||
* Whether an adapter serves the current target's route, as the host reports
|
||||
* Whether an adapter serves the current selection's provider, as the host reports
|
||||
* it — null before the first load, which is NOT the same as blocked. Read
|
||||
* this rather than "current matches no group": catalog membership is
|
||||
* advisory, so a route serving a model it stopped advertising is missing
|
||||
@@ -57,7 +57,7 @@ export class ModelDirectory {
|
||||
|
||||
/**
|
||||
* Refresh the advisory directory (both entries call this on open).
|
||||
* Failure preserves the last good groups and current target.
|
||||
* Failure preserves the last good groups and current selection.
|
||||
* @returns the fresh directory value.
|
||||
*/
|
||||
async load(): Promise<SessionModels> {
|
||||
@@ -86,22 +86,22 @@ export class ModelDirectory {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the complete provider/model/reasoning target (both entries submit through here). Success
|
||||
* Select the complete provider/model/reasoning selection (both entries submit through here). Success
|
||||
* updates the shared current; failure surfaces on the store and throws so
|
||||
* each entry's own retry surface engages.
|
||||
* @param target - provider, provider-owned model id, and optional adapter-owned effort.
|
||||
*/
|
||||
async select(target: ModelTarget): Promise<void> {
|
||||
* @param selection - provider, provider-owned model id, and optional adapter-owned effort.
|
||||
*/
|
||||
async select(selection: ModelSelection): Promise<void> {
|
||||
this.assertAvailable()
|
||||
const generation = ++this.generation
|
||||
this.store.update((s) => { s.status = 'selecting'; s.error = null })
|
||||
const { result } = await this.sessions.selectModel({
|
||||
sessionId: this.sessionId,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
...target.reasoningEffort === undefined
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
...selection.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: target.reasoningEffort },
|
||||
: { reasoningEffort: selection.reasoningEffort },
|
||||
})
|
||||
if (this.disposed || generation !== this.generation) {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
@@ -124,7 +124,7 @@ export class ModelDirectory {
|
||||
/**
|
||||
* Drop the previous Host generation's projection and repull it. Clearing
|
||||
* first prevents an unconsumed process-local selection from being displayed
|
||||
* while the restarted Host has restored the last logged request target.
|
||||
* while the restarted Host has restored the last logged model selection.
|
||||
*/
|
||||
resetConnected(): void {
|
||||
if (this.disposed) return
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* contribution and the composer's named `conversation.input.model` seat both
|
||||
* load the session's provider-grouped advisory directory (`session.models`)
|
||||
* and submit through `session.selectModel` via the same directory instance,
|
||||
* so the host-reported current target is the single fact both surfaces echo
|
||||
* so the host-reported current selection is the single fact both surfaces echo
|
||||
* — a switch made in either entry is what the other shows next. Failures
|
||||
* ride each entry's own retry surface (popup shell error/retry; seat menu
|
||||
* inline error) without forking the state. Addressed subagent sessions expose
|
||||
* neither entry because those Agent-bound RPCs would activate persisted
|
||||
* history outside the direct-parent continuation seam.
|
||||
*/
|
||||
import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelSelection, SessionModels } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
|
||||
@@ -68,13 +68,13 @@ function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOpt
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a picked row back to its target by matching against the loaded
|
||||
* Resolve a picked row back to its model selection by matching against the loaded
|
||||
* groups (the same data the rows were built from — ids stay opaque).
|
||||
* @param state - the session's directory snapshot.
|
||||
* @param id - the picked row id.
|
||||
* @returns the row's target, or undefined for failure rows / stale ids.
|
||||
* @returns the row's model selection, or undefined for failure rows / stale ids.
|
||||
*/
|
||||
function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefined {
|
||||
function selectionOf(state: ModelDirectoryState, id: string): ModelSelection | undefined {
|
||||
for (const group of state.groups) {
|
||||
for (const model of group.models) {
|
||||
if (rowId(group.id, model.id) !== id) continue
|
||||
@@ -139,11 +139,11 @@ export function apply(ctx: ClientContext): void {
|
||||
throw new Error('model selection is unavailable for addressed subagent sessions')
|
||||
}
|
||||
const directory = models.directoryFor(session.sessionId)
|
||||
const target = targetOf(directory.store.getSnapshot(), option.id)
|
||||
if (target === undefined) {
|
||||
const selection = selectionOf(directory.store.getSnapshot(), option.id)
|
||||
if (selection === undefined) {
|
||||
throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group')
|
||||
}
|
||||
await directory.select(target)
|
||||
await directory.select(selection)
|
||||
},
|
||||
},
|
||||
}), 'ui-model: /model contribution')
|
||||
@@ -165,8 +165,8 @@ export function apply(ctx: ClientContext): void {
|
||||
load: () => {
|
||||
if (available) directory.load().catch(() => { /* surfaced on the store */ })
|
||||
},
|
||||
select: (target: ModelTarget) => available
|
||||
? directory.select(target).then(() => true, () => false)
|
||||
select: (selection: ModelSelection) => available
|
||||
? directory.select(selection).then(() => true, () => false)
|
||||
: Promise.resolve(false),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* entry; this package only contributes the single occupant, so no SlotMap
|
||||
* merge lives here.
|
||||
*/
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
|
||||
@@ -17,9 +17,9 @@ export interface ModelSelectInjected {
|
||||
/** Refresh the advisory directory (fire-and-forget; errors land on the store). */
|
||||
load: () => void
|
||||
/**
|
||||
* Select a complete provider/model/reasoning target through the shared route.
|
||||
* @param target - model target and optional adapter-owned effort.
|
||||
* Select a complete provider/model/reasoning selection.
|
||||
* @param selection - model selection and optional adapter-owned effort.
|
||||
* @returns whether the host accepted the selection.
|
||||
*/
|
||||
select: (target: ModelTarget) => Promise<boolean>
|
||||
select: (selection: ModelSelection) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
@@ -55,7 +55,7 @@ const GROUPS = [{
|
||||
/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
let current: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
let current: ModelSelection = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
|
||||
const calls = { models: 0, select: 0 }
|
||||
ctx.provide('connection', { api: { sessions: {
|
||||
models: () => {
|
||||
@@ -125,7 +125,7 @@ async function bench() {
|
||||
contribution: () => contribution!,
|
||||
seat: () => seats.get('conversation.input.model')!,
|
||||
hostCurrent: () => current,
|
||||
setHostCurrent: (target: ModelTarget) => { current = target },
|
||||
setHostCurrent: (selection: ModelSelection) => { current = selection },
|
||||
address: (id: SessionId) => { addressed.add(id) },
|
||||
setRoutable: (next: boolean) => { routable = next },
|
||||
blockOf: (key: string) => blocks.get(sid(key)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ModelDirectoryState } from '../src/client/directory.ts'
|
||||
@@ -48,10 +48,10 @@ function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryStat
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ModelSelect reasoning effort', () => {
|
||||
it('renders adapter metadata and submits the effort as part of the session target', async () => {
|
||||
it('renders adapter metadata and submits the effort as part of the session selection', async () => {
|
||||
const directory = createSnapshotStore<ModelDirectoryState>(state())
|
||||
const select = vi.fn(async (target: ModelTarget) => {
|
||||
directory.set(state({ current: target }))
|
||||
const select = vi.fn(async (selection: ModelSelection) => {
|
||||
directory.set(state({ current: selection }))
|
||||
return true
|
||||
})
|
||||
render(<ModelSelect
|
||||
@@ -112,7 +112,7 @@ describe('ModelSelect reasoning effort', () => {
|
||||
.toEqual(['Default', 'Standard'])
|
||||
})
|
||||
|
||||
it('prompts for a new selection when the current target is no longer advertised', () => {
|
||||
it('prompts for a selection when the current model is no longer advertised', () => {
|
||||
const directory = createSnapshotStore(state({
|
||||
current: { provider: 'deepseek-official', model: 'removed-model' },
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user