fix(account): limit automatic model selection to account login

This commit is contained in:
lsdsjy committed 2026-09-23 15:06:00 +08:00
1 parent 40da69ff75
commit 9e19ca3e45
34 files changed
+68 -206

No files matched your search

+2 -2
View File
@@ -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 docs/subsystems/core.md
core.md: 36fee2dfea5e38229f16ddbca2da567eeca0f2c5
core.zh.md: c807828249fde64df291ca098922829107515d1d
core.md: aa9af309c03ebedae353139c4017d19f2607dd7d
core.zh.md: b18ff5bba1c0309e9b9dcdf1f3e97adb5895caf6
-7
View File
@@ -437,13 +437,6 @@ currentSelection(): ModelSelection
* @returns fulfillment after the optional profile write settles.
*/
async saveSelection(next: ModelSelection): Promise<void>
/**
* Store the initial setup choice only before an explicit profile selection exists.
* @param next - available model belonging to the initialized provider.
* @returns after the initial choice is saved or an existing choice is retained.
*/
async initializeSelection(next: ModelSelection): Promise<void>
```
Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts)
-7
View File
@@ -447,13 +447,6 @@ currentSelection(): ModelSelection
* @returns fulfillment after the optional profile write settles.
*/
async saveSelection(next: ModelSelection): Promise<void>
/**
* Store the initial setup choice only before an explicit profile selection exists.
* @param next - available model belonging to the initialized provider.
* @returns after the initial choice is saved or an existing choice is retained.
*/
async initializeSelection(next: ModelSelection): Promise<void>
```
Source: [`packages/core/agent-default-model/src/index.ts`](../../packages/core/agent-default-model/src/index.ts)
+2 -2
View File
@@ -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 docs/subsystems/session.md
session.md: 8c060b1a34f9f16ebf0ececd28ecdf24e77f497b
session.zh.md: dc26e5a5dc15251bef1fb93cc53ceff2fee55ab6
session.md: e55ebf703c5bd256198687b868831f1741c30906
session.zh.md: 4143f2366c3b69395b72e70afa1552245337f8de
+2 -3
View File
@@ -821,11 +821,10 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue>
/**
* Initialize a provider default; account login replaces it only when no provider API key is configured.
* @param provider - the provider whose credential was configured.
* Select the first available account model after login when no provider API key is configured.
* @returns after saving the first available model or retaining the existing default.
*/
@Remote async initializeDefaultModel(provider: string): Promise<void>
@Remote async initializeDefaultModel(): Promise<void>
/**
* Describe every currently routable model for Host-generation selectors.
+2 -3
View File
@@ -825,11 +825,10 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue>
/**
* Initialize a provider default; account login replaces it only when no provider API key is configured.
* @param provider - the provider whose credential was configured.
* Select the first available account model after login when no provider API key is configured.
* @returns after saving the first available model or retaining the existing default.
*/
@Remote async initializeDefaultModel(provider: string): Promise<void>
@Remote async initializeDefaultModel(): Promise<void>
/**
* Describe every currently routable model for Host-generation selectors.
@@ -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/api/session-controller/README.md
README.md: abe79ff7b2b4be6f2b0ba626c77ad9af22fa2f33
README.zh.md: 7468ed80cb741574a8ab79422ee363af2bbad3e4
README.md: b3307ce9d6eaab4f5247214531f354228b640172
README.zh.md: 4479d3f60046f0bd724a9b6cf42bcace63da20c5
+1 -1
View File
@@ -78,7 +78,7 @@ References keep local Session data, scoped Contexts, and history streams alive,
`SessionMediaReferences` mounts `GET|HEAD /api/file?path=<absolute path>` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work.
GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel(provider)` saves the first available model of an API-key provider only before any user choice exists. Account login instead replaces the default when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection.
GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection.
-----
+1 -1
View File
@@ -78,7 +78,7 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以
当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。
GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel(provider)` 仅在用户尚未选择时,保存 API key 提供方的第一个可用模型。账号登录则在其他提供方均未配置 API key 时替换默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。
GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。
-----
+5 -6
View File
@@ -285,13 +285,13 @@ export class SessionController extends TypertRemoteService {
}
/**
* Initialize a provider default; account login replaces it only when no provider API key is configured.
* @param provider - the provider whose credential was configured.
* Select the first available account model after login when no provider API key is configured.
* @returns after saving the first available model or retaining the existing default.
*/
@Remote
async initializeDefaultModel(provider: string): Promise<void> {
if (provider === 'deepseek-account' && await hasProviderApiKey(this.ctx)) return
async initializeDefaultModel(): Promise<void> {
const provider = 'deepseek-account'
if (await hasProviderApiKey(this.ctx)) return
const catalog = await buildModelCatalog(this.ctx)
const model = catalog.groups.find(group => group.id === provider)?.models[0]
if (model === undefined) throw new RemoteError('session/provider-models-unavailable',
@@ -299,8 +299,7 @@ export class SessionController extends TypertRemoteService {
const selection = { provider, model: model.id,
...model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: ReasoningEffortId(model.reasoning.defaultEffort) },
}
if (provider === 'deepseek-account') await this.ctx.agentDefaultModel.saveSelection(selection)
else await this.ctx.agentDefaultModel.initializeSelection(selection)
await this.ctx.agentDefaultModel.saveSelection(selection)
}
/**
@@ -345,26 +345,6 @@ describe('Web session model selection', () => {
expect(readImage).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('initializes only the first explicitly configured provider and retains later user choices', async () => {
const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts')
const configured = await configurationFixture({ hmr: false })
const { ctx } = await harness(undefined, configured.ctx)
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp',
})
await controller.initializeDefaultModel('deepseek-official')
expect(ctx.agentDefaultModel.currentSelection()).toEqual({
provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high',
})
await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'removed' })
await controller.initializeDefaultModel('deepseek-official')
expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'removed', model: 'removed' })
await expect(controller.initializeDefaultModel('empty')).rejects.toMatchObject({
code: 'session/provider-models-unavailable', details: { provider: 'empty' },
})
await ctx.fiber.dispose()
})
it('checks configured key references even for empty catalogs and skips profiles without keys', async () => {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
@@ -417,7 +397,7 @@ describe('Web session model selection', () => {
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp',
})
await controller.initializeDefaultModel('deepseek-account')
await controller.initializeDefaultModel()
expect(describe).toHaveBeenCalledWith('CUSTOM_API_KEY')
expect(ctx.agentDefaultModel.currentSelection()).toEqual(configuredKey
? { provider: 'removed', model: 'saved' }
@@ -864,18 +844,23 @@ describe('Web session model selection', () => {
})
})
it('initializes a provider without reasoning metadata', async () => {
it('initializes the account model without reasoning metadata and rejects an empty account catalog', async () => {
const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts')
const configured = await configurationFixture({ hmr: false })
const { ctx } = await harness(undefined, configured.ctx)
ctx.llm.registerAdapter(['simple'], new CatalogAdapter('Simple', [
{ provider: 'simple', id: 'basic', name: 'Basic' },
ctx.provide('credentials', { describe: vi.fn() } as never)
const dispose = ctx.llm.registerAdapter(['deepseek-account'], new CatalogAdapter('Account', [
{ provider: 'deepseek-account', id: 'basic', name: 'Basic' },
]))
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp',
})
await controller.initializeDefaultModel('simple')
expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'simple', model: 'basic' })
await controller.initializeDefaultModel()
expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-account', model: 'basic' })
dispose()
await expect(controller.initializeDefaultModel()).rejects.toMatchObject({
code: 'session/provider-models-unavailable', details: { provider: 'deepseek-account' },
})
})
it('reports a non-Error catalog rejection as an unavailable selection', async () => {
@@ -78,7 +78,7 @@ export function apply(ctx: Context): void {
frame.accept()
if (initialize) void (async () => {
try {
const initialized = await ctx.remote.session.initializeDefaultModel('deepseek-account')
const initialized = await ctx.remote.session.initializeDefaultModel()
if (!initialized.ok) console.info('[deepseek-account] default model initialization failed', { reason: 'refused' })
} catch (_error) {
console.info('[deepseek-account] default model initialization failed', { reason: 'disconnected' })
@@ -58,7 +58,7 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize
try {
apply(ctx as never)
await done.promise
expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith('deepseek-account')
expect(initializeDefaultModel).toHaveBeenCalledExactlyOnceWith()
expect(observed).toEqual([
{ status: 'credential-stored', failed: false },
{ status: 'credential-stored', failed: false },
@@ -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-settings-models/README.md
README.md: 52d07e1cdaa766910dcc2e5f72c989ee20cae860
README.zh.md: 552ce06ab93c847bd49abe836786521461a2e41a
README.md: aac5663813d831ae8484e2592306e1a7eda5eff6
README.zh.md: 26fde64f97560c526b35df62161d521193df3a0e
+2 -2
View File
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Saving credentials or a custom provider completes independently of default-model initialization. Initialization failures are logged; they do not report a successful save as failed or prevent closing the editor. The user can select an available model from the composer.
Saving credentials or a custom provider preserves the selected model. The user can select an available model from the composer.
DeepSeek Account appears first and DeepSeek second in the provider list; third-party providers retain their directory order.
@@ -59,7 +59,7 @@ After the versioned notice step completes, the DeepSeek step projects first-run
The section declares two seats for plugins distributed outside this repository, typed in [`src/client/slot-contract.ts`](src/client/slot-contract.ts) and exported from `./client`. `settings.models.provider-card` (keyed) renders inside every card that shows a directory row — a saved row's card, its first-run setup posture, and the add-provider draft — dispatched with `entryKey = settingsNs` and owner props carrying the row's `ConfigurableProviderView`, its configured state, and its confirmed api-key credential state, so one registration under an adapter family's namespace receives every card of that family, hand-declared routes included; the hand-declared draft card has no directory row yet and dispatches nothing until saved. `settings.models.footer` (list) renders after the rows and the add controls. A registrant activates through `ctx.slots.inject` with a type-only import of this package's `/client` entry; without registrants both seats render nothing.
The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available. The first successful API-key setup initializes the default from its own provider; later setup preserves the user’s saved model choice.
The Models page includes **DeepSeek Account** (`deepseek-account`, **DeepSeek 账号** in Chinese). Its editor exposes the shared DeepSeek model catalog without API-key or base-URL inputs. The account row is hidden when its available model catalog is empty, including before sign-in and after sign-out; it returns when account models become available.
-----
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
凭据或自定义提供方保存成功后,不等待默认模型初始化即可完成操作。初始化失败记录日志,不会将已成功的保存报告为失败,也不会阻止编辑器关闭。用户可在 composer 中选择可用模型。
保存凭据或自定义提供方时保留已选模型。用户可在 composer 中选择可用模型。
提供方列表中 DeepSeek 账号排第一,DeepSeek 排第二;第三方提供方保留目录中的相对顺序。
@@ -59,7 +59,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会
本分区为仓库外分发的插件声明两个席位,类型定义在 [`src/client/slot-contract.ts`](src/client/slot-contract.ts) 并从 `./client` 导出。`settings.models.provider-card`(keyed)渲染在每张展示目录行的卡片内部——已保存行的卡片、其首次运行 setup 形态、以及「添加提供商」草稿卡——以 `entryKey = settingsNs` 分发,owner props 携带该行的 `ConfigurableProviderView`、其 configured 状态与已确认的 api-key 凭据状态,因此以某适配器家族的 namespace 注册一次即可收到该家族的全部卡片,含手工声明的路由;手工声明的草稿卡尚无目录行,保存之前不分发。`settings.models.footer`(list)渲染在行列表与新增控件之后。注册方通过 `ctx.slots.inject` 激活,并以 type-only import 引入本包 `/client` 入口;没有注册方时两个席位均不渲染任何内容。
Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。首次成功配置 API Key 时,从对应提供方初始化默认模型;后续配置保留用户已保存的模型选择。
Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepSeek Account**)。其编辑器展示共享的 DeepSeek 模型目录,不提供 API Key 或 Base URL 输入框。账号可用模型目录为空时隐藏账号行,包括登录前和退登后;账号模型恢复可用时重新显示。
-----
@@ -189,7 +189,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
// and the retry above now goes straight back to this write.
if (stored !== undefined) return stored
}
void operations.initializeModel(route.trim())
return undefined
}
@@ -298,7 +298,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
if (keyValue.length > 0) {
const stored = await operations.storeCredential(keyRef, keyValue)
if (stored !== undefined) return stored
void operations.initializeModel(props.provider)
}
setKeyDraft('')
return undefined
@@ -32,11 +32,6 @@ export type ModelDiscoveryOutcome =
/** The Host operations the Models page and its cards invoke. */
export interface ModelsOperations {
/**
* @param provider - newly configured provider.
* @returns after optional initialization; failures are logged without changing the save outcome.
*/
initializeModel(provider: string): Promise<void>
/**
* Read one credential reference's state.
* @param ref - credential reference name.
@@ -86,14 +81,6 @@ export interface ModelsOperations {
*/
export function createModelsOperations(ctx: ClientContext): ModelsOperations {
return {
initializeModel: async (provider) => {
try {
const response = await ctx.remote.session.initializeDefaultModel(provider)
if (!response.ok) console.info('[models] default model initialization failed', { provider, reason: 'refused' })
} catch (_error) {
console.info('[models] default model initialization failed', { provider, reason: 'disconnected' })
}
},
describeCredential: async (ref) => {
const response = await ctx.remote.credentials.describe([ref])
return response.ok ? response.value[ref] : undefined
@@ -1114,27 +1114,14 @@ describe('ModelsSection', () => {
expect(baseURL.value).toBe('')
})
it.each(['refused', 'disconnected', 'pending'] as const)('finishes credential saving when default initialization is %s', async (outcome) => {
const log = vi.spyOn(console, 'info').mockImplementation(() => {})
const pending = Promise.withResolvers<never>()
it('saves credentials without changing the default model', async () => {
const { ctx, set } = await mountDeepSeekCard()
const initialize = vi.spyOn(ctx.remote.session, 'initializeDefaultModel')
if (outcome === 'pending') initialize.mockReturnValue(pending.promise)
else if (outcome === 'disconnected') initialize.mockRejectedValue(new Error('offline'))
else initialize.mockResolvedValue({ ok: false,
error: new RemoteError('session/provider-models-unavailable', 'empty catalog', { provider: 'deepseek-official' }) })
try {
fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
expect(set).toHaveBeenCalledOnce()
expect(initialize).toHaveBeenCalledExactlyOnceWith('deepseek-official')
expect(screen.queryByText('empty catalog')).toBeNull()
} finally {
if (outcome === 'pending') { pending.reject(new Error('closed')); await Promise.resolve() }
initialize.mockRestore()
log.mockRestore()
}
fireEvent.change(await screen.findByLabelText(en.keyInput), { target: { value: 'test-key' } })
fireEvent.click(screen.getByText(en.apply))
await waitFor(() => { expect(screen.queryByText(en.apply)).toBeNull() })
expect(set).toHaveBeenCalledOnce()
expect(initialize).not.toHaveBeenCalled()
})
it('rejects an invalid draft before writing', async () => {
@@ -12,7 +12,6 @@ function operations(discoverModels: ModelsOperations['discoverModels']): ModelsO
return {
discoverModels,
describeCredential: vi.fn(),
initializeModel: vi.fn(),
storeCredential: vi.fn(),
removeCredential: vi.fn(),
writeSettings: vi.fn(),
@@ -861,28 +861,19 @@ describe('hand-declared providers', () => {
return { ...scripted, onClose }
}
it('closes a saved custom provider when default initialization is refused', async () => {
const log = vi.spyOn(console, 'info').mockImplementation(() => {})
it('creates a custom provider without changing the default model', async () => {
const { face, mutate, set, onClose } = mountCard()
const initialize = vi.spyOn(ctxWith(face).remote.session, 'initializeDefaultModel')
.mockResolvedValue({ ok: false, error: new RemoteError('session/provider-models-unavailable',
'empty catalog', { provider: 'acme-gateway' }) })
try {
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(mutate).toHaveBeenCalledOnce()
expect(set).toHaveBeenCalledOnce()
expect(initialize).toHaveBeenCalledExactlyOnceWith('acme-gateway')
expect(screen.queryByText('empty catalog')).toBeNull()
} finally {
initialize.mockRestore()
log.mockRestore()
}
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.example/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'model' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(mutate).toHaveBeenCalledOnce()
expect(set).toHaveBeenCalledOnce()
expect(initialize).not.toHaveBeenCalled()
})
it('writes the whole profile and the key under the derived reference', async () => {
@@ -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/core/agent-default-model/README.md
README.md: 2aa3d8ed4e4f36286226f76df4c21dd5cb87be0d
README.zh.md: 7283a44f5f491c1fd51d4c33b6df3f65cb8e76bf
README.md: d98747c457a1065453f88779a8bb75f3219c8e2d
README.zh.md: 9765b1aab7b7d9075ce8e9d94cef27934bf8a1e7
@@ -7,8 +7,6 @@ kind: "package-reference"
English | [中文](README.zh.md)
`initializeSelection()` serializes the first setup choice with user writes and checks the persisted profile override before writing. It preserves explicit selections, including unavailable models.
## Summary
Give newly created agents a shared default provider and model when their sessions do not specify one. Provider, model, and reasoning effort are live Config fields. Saved selections update the active profile patch and apply to subsequent reads; per-session selection remains owned by the entry point.
@@ -7,8 +7,6 @@ kind: "package-reference"
[English](README.md) | 中文
`initializeSelection()` 将首次配置选择与用户写入串行处理,并在写入前检查持久化的 profile override。它保留显式选择,包括暂不可用的模型。
## 概述
为会话未指定模型的新 agent 提供共享默认 provider 和模型。Provider、模型和推理强度都是即时 Config 字段。保存的选择更新当前 profile patch,并用于后续读取;会话级选择仍由入口负责。
+6 -28
View File
@@ -52,8 +52,6 @@ export class AgentDefaultModelConfig extends Service {
reasoningEffort: z.string().volatile(),
})
private writes: Promise<void> = Promise.resolve()
constructor(private readonly ownerContext: Context, private config: Config) {
super(ownerContext, 'agentDefaultModel')
@@ -79,32 +77,12 @@ export class AgentDefaultModelConfig extends Service {
* @returns fulfillment after the optional profile write settles.
*/
async saveSelection(next: ModelSelection): Promise<void> {
return this.writeSelection(next, false)
}
/**
* Store the initial setup choice only before an explicit profile selection exists.
* @param next - available model belonging to the initialized provider.
* @returns after the initial choice is saved or an existing choice is retained.
*/
async initializeSelection(next: ModelSelection): Promise<void> {
return this.writeSelection(next, true)
}
private writeSelection(next: ModelSelection, initialize: boolean): Promise<void> {
const operation = this.writes.then(async () => {
const entry = this.ownerContext.fiber.entry
const editor = this.ctx.get('configEditor')
if (entry === undefined || editor === undefined) return
const override = editor.configuration().find(row => row.entry === entry)?.override
if (initialize && override !== undefined && ('provider' in override || 'model' in override)) return
await editor.edit(entry, () => ({
provider: next.provider, model: next.model,
...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) },
}))
})
this.writes = operation.catch(() => { /* A refused write does not block the next user choice. */ })
return operation
const entry = this.ownerContext.fiber.entry
if (entry === undefined) return
await this.ctx.get('configEditor')?.edit(entry, () => ({
provider: next.provider, model: next.model,
...next.reasoningEffort === undefined ? {} : { reasoningEffort: String(next.reasoningEffort) },
}))
}
}
@@ -31,35 +31,3 @@ it('persists complete selections through its owning profile entry', async () =>
await standalone.agentDefaultModel.saveSelection({ provider: 'test', model: 'ignored' })
expect(standalone.agentDefaultModel.currentSelection().model).toBe('original')
})
it('initializes once and retains explicit profile choices after restart', async () => {
const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts')
const { ctx, start } = await configurationFixture({ hmr: false })
const defaults = ctx.agentDefaultModel
await Promise.all([
defaults.initializeSelection({ provider: 'deepseek-account', model: 'deepseek-flash' }),
defaults.initializeSelection({ provider: 'third-party', model: 'other' }),
])
expect(defaults.currentSelection()).toEqual({ provider: 'deepseek-account', model: 'deepseek-flash' })
await defaults.saveSelection({ provider: 'third-party', model: 'chosen' })
const restarted = await start()
await restarted.agentDefaultModel.initializeSelection({ provider: 'deepseek-account', model: 'deepseek-flash' })
expect(restarted.agentDefaultModel.currentSelection()).toEqual({ provider: 'third-party', model: 'chosen' })
})
it('allows initialization after a refused write and retains a model-only override', async () => {
const { configurationFixture } = await import('../../../settings/settings/tests/configuration-fixture.ts')
const { vi } = await import('vitest')
const { ctx, start } = await configurationFixture({ hmr: false })
const edit = vi.spyOn(ctx.configEditor, 'edit').mockRejectedValueOnce(new Error('write failed'))
onTestFinished(() => { edit.mockRestore() })
await expect(ctx.agentDefaultModel.saveSelection({ provider: 'test', model: 'refused' })).rejects.toThrow('write failed')
await ctx.agentDefaultModel.initializeSelection({ provider: 'test', model: 'initialized' })
expect(ctx.agentDefaultModel.currentSelection().model).toBe('initialized')
const restarted = await start()
const rows = restarted.configEditor.configuration().map(row => ({ ...row, override: { model: 'initialized' } }))
const source = vi.spyOn(restarted.configEditor, 'configuration').mockReturnValue(rows)
onTestFinished(() => { source.mockRestore() })
await restarted.agentDefaultModel.initializeSelection({ provider: 'test', model: 'ignored' })
expect(restarted.agentDefaultModel.currentSelection().model).toBe('initialized')
})
+2 -2
View File
@@ -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/core/agent-loop/README.md
README.md: 07033fb3204e827c5227a99e7211e5e648c389c6
README.zh.md: 0fce6147b37d704add8266deaf185ea83ae84b73
README.md: 4c6d3df10cc9ab22a9c00232c9181a96d1829031
README.zh.md: a3c34e153ea38a4392ba0a272499f59d5492ecaa
-1
View File
@@ -25,7 +25,6 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Mount `dsh-agent-loop` in any composition that should run agents. It supplies the driver behind `ctx.agents` and starts any agents you declare in its config; both [`dsh-base`](../../bundle/base/README.md) and [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.md) mount it as an explicit row.
### Configure declarative agents
-1
View File
@@ -25,7 +25,6 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
在任何应运行 agent 的组合中挂载 `dsh-agent-loop`。它提供 `ctx.agents` 背后的驱动器,并启动你在配置中声明的 agent;[`dsh-base`](../../bundle/base/README.zh.md) 与 [`dsh-sdk-minimal`](../../bundle/sdk-minimal/README.zh.md) 都将它作为显式配置行挂载。
### 配置声明式 agent
+2 -2
View File
@@ -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/core/agent/README.md
README.md: 17ee5fd723f5322c0ccdfd7cc8443dbb842cf674
README.zh.md: 94424a5e9bc5a30fbbb5e775658c7b90a37f24f7
README.md: be003d7b899306b58c4ddc88e720c18dde9ce33b
README.zh.md: e360ba975dddc381e25b69766b1df82f6e45b3b4
-1
View File
@@ -25,7 +25,6 @@ Use `dsh-agent` to create or resume live agents, send follow-up or steering inpu
<a id="use-this-package"></a>
## Use this package
Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the `Agent` handle that plugins, UI, hooks, and orchestrators work against. The service is inert until a driver registers a factory — the shipped driver is `dsh-agent-loop`, so the smallest useful composition loads both.
### Create or resume an agent
-1
View File
@@ -25,7 +25,6 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
在存在实时 agent 的任何地方挂载 `dsh-agent`:它提供 `ctx.agents` 以及插件、UI、钩子和编排器所面向编程的 `Agent` 句柄。在没有驱动器注册工厂之前,该服务保持惰性——随附驱动器是 `dsh-agent-loop`,因此最小的可用组合需要同时加载两者。
### 创建或恢复 agent
@@ -98,12 +98,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'next', description: 'resolved selection accepted by an entry point.' }],
returns: 'fulfillment after the optional profile write settles.',
},
{
signature: 'async initializeSelection(next: ModelSelection): Promise<void>',
description: 'Store the initial setup choice only before an explicit profile selection exists.',
parameters: [{ name: 'next', description: 'available model belonging to the initialized provider.' }],
returns: 'after the initial choice is saved or an existing choice is retained.',
},
],
},
{
@@ -1832,9 +1826,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the normalized selection installed for the Session.',
},
{
signature: '@Remote async initializeDefaultModel(provider: string): Promise<void>',
description: 'Initialize a provider default; account login replaces it only when no provider API key is configured.',
parameters: [{ name: 'provider', description: 'the provider whose credential was configured.' }],
signature: '@Remote async initializeDefaultModel(): Promise<void>',
description: 'Select the first available account model after login when no provider API key is configured.',
parameters: [],
returns: 'after saving the first available model or retaining the existing default.',
},
{