From 497e3cb7fd1402037bc022c5966115fc425d9b0c Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 10 Sep 2026 15:33:11 +0800 Subject: [PATCH 1/3] feat(providers): show each agent the share of a picked set it can run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings named a model set the agent's own protocol could not fully reach, so a gateway account with responses-only coverage left codex with a silently short — or empty — picker. --- .../settings/providers/__tests__/view.test.ts | 36 +++++++- .../workbench/src/settings/providers/view.ts | 17 +++- packages/foundation/providers/AGENTS.md | 15 +++- .../src/__tests__/enabled-models.test.ts | 25 +++++- .../providers/src/enabled-models.ts | 20 +++++ packages/foundation/providers/src/index.ts | 7 +- packages/presentation/i18n/src/locales/en.ts | 2 + .../presentation/i18n/src/locales/zh-cn.ts | 2 + .../shell/__tests__/account-detail.test.tsx | 84 +++++++++++++++++++ .../ui/src/shell/providers/account-detail.tsx | 19 ++++- 10 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index bbeb01544..5664ca14c 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -1,10 +1,11 @@ -import type { Accounts, AgentRuntimes, ProvidersConfig } from '@linkcode/schema'; +import type { AccountModel, Accounts, AgentRuntimes, ProvidersConfig } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import { updateAccountFromDraft } from '../add-flow'; import { accountConfigSnippet, boundAgentKinds, maskSecret, + providerAccountDetailViewModel, providerAccountListViewModel, withAccountEnabled, withoutAccount, @@ -153,6 +154,39 @@ describe('view helpers', () => { }); }); + it('tells each agent row how much of the picked set it can actually run', () => { + const claudeOnly: AccountModel = { + id: 'anthropic/claude-sonnet-5', + protocols: ['openai-chat', 'anthropic'], + }; + const gateway: Accounts[number] = { + id: 'acc_gw', + label: 'LinkCode Gateway', + createdAt: 0, + service: 'linkcode-gateway', + credential: { type: 'auth-token', token: 'lc-test' }, + models: [ + { id: 'openai/gpt-5.6', protocols: ['openai-chat', 'openai-responses'] }, + claudeOnly, + ], + }; + const codexRow = (account: Accounts[number]) => + providerAccountDetailViewModel(account, undefined, undefined).agents.find( + ({ kind }) => kind === 'codex', + ); + + const shortfall = codexRow(gateway); + expect(shortfall?.models).toEqual({ picked: 2, reachable: 1 }); + // A shortfall is not a reason the row is off, so it carries no status of its own. + expect(shortfall?.status).toBeUndefined(); + + // Pick only what codex cannot reach and the row has to say why its picker is empty. + const empty = codexRow({ ...gateway, models: [claudeOnly] }); + expect(empty?.enabled).toBe(true); + expect(empty?.models).toEqual({ picked: 1, reachable: 0 }); + expect(empty?.status).toEqual({ kind: 'no-reachable-model' }); + }); + it('updates editable account fields without replacing its identity or hidden fields', () => { const account: Accounts[number] = { id: 'acc_a', diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 7bb01a4b4..604ee404e 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -1,5 +1,6 @@ import { accountEnabledFor, + accountModelReach, pinnedEndpoint, resolveBinding, serviceById, @@ -114,13 +115,27 @@ function agentStatus( } // Enabled is the whole state, and the switch already shows it — only a reason to be off earns text. const enabled = accountEnabledFor(providers, kind, account.id); + const models = accountModelReach(account, kind); + const status = offerStatus(enabled, models); return { tier: availability.tier, enabled, - ...(!enabled && { status: { kind: 'disabled' } }), + ...(models.picked > 0 && { models }), + ...(status !== undefined && { status }), }; } +/** Why an available agent offers nothing, when that is not obvious from its switch. */ +function offerStatus( + enabled: boolean, + models: { picked: number; reachable: number }, +): ProviderAgentStatus | undefined { + if (!enabled) return { kind: 'disabled' }; + // An enabled agent whose picker comes up empty reads as an enablement bug; the picked set is the + // real reason, so the row names it instead of leaving the switch to imply otherwise. + return models.picked > 0 && models.reachable === 0 ? { kind: 'no-reachable-model' } : undefined; +} + /** Selected account plus precomputed binding rows; UI owns only rendering and local interaction. */ export function providerAccountDetailViewModel( account: Account, diff --git a/packages/foundation/providers/AGENTS.md b/packages/foundation/providers/AGENTS.md index 21b772e5a..f98d34785 100644 --- a/packages/foundation/providers/AGENTS.md +++ b/packages/foundation/providers/AGENTS.md @@ -39,14 +39,23 @@ Pure data plus pure functions: no hooks, no browser APIs, no I/O. Its only depen resolver about the same account — showing a pinned endpoint for one that resolves per agent. Display, edit-form prefill, and resolution have to answer the question identically. - **`models` is service-level and spelled out, never derived.** One secret reaches one model list, - and the ids are identical whichever protocol shape an agent resolves to — so the list belongs to - the service, not the variant, and one fetch serves every agent bound to the account. The URL is - written out because deriving it from a variant's `baseUrl` + protocol is wrong wherever variants + and for every service but one the ids are identical whichever protocol shape an agent resolves to + — so the list belongs to the service, not the variant, and one fetch serves every agent bound to + the account. The URL is written out because deriving it from a variant's `baseUrl` + protocol is + wrong wherever variants sit on different paths: DeepSeek's `/anthropic` variant would give `/anthropic/v1/models` and Vercel's bare-origin one a root `/models`, neither of which exists. `wire` picks the auth header and response shape only. Absent means the service serves no list, and the account is freeform-only — true for both Cloudflare entries, whose `/compat` route has no model-list path (docs + verified live). Anthropic's list defaults to `limit=20`, so the full list must be asked for. + - **`ServiceVariant.models` overrides it for a service whose ids differ by wire.** LinkCode + Gateway is the one: it serves every model on Chat Completions but only a subset on Responses and + on Messages, and answers `GET /v1/models?protocol=…` with exactly that subset. The daemon's + probe fetches each distinct list once and tags every model with the protocols whose list named + it (`AccountModel.protocols`, `packages/host/engine` `model-probe.ts`), which is what lets + `enabledAccountModels` keep a model out of an agent's picker instead of letting the agent's + first request 404. A model with no tags predates the probe and stays offered everywhere — only + an explicit set narrows anything, so re-detecting an old account is what tags it. - **A missing variant is a claim about the vendor, so verify it.** Omitting `openai-responses` refuses codex outright, and an unverified assumption that "that endpoint doesn't serve it anyway" once shipped exactly that gap for xAI, OpenRouter and Vercel — all three do serve diff --git a/packages/foundation/providers/src/__tests__/enabled-models.test.ts b/packages/foundation/providers/src/__tests__/enabled-models.test.ts index 9f6cc6335..f2ae7b66c 100644 --- a/packages/foundation/providers/src/__tests__/enabled-models.test.ts +++ b/packages/foundation/providers/src/__tests__/enabled-models.test.ts @@ -1,6 +1,6 @@ import type { Account, Accounts } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; -import { accountEnabledFor, enabledAccountModels } from '../enabled-models'; +import { accountEnabledFor, accountModelReach, enabledAccountModels } from '../enabled-models'; function account(id: string, overrides: Partial = {}): Account { return { @@ -91,6 +91,29 @@ describe('enabledAccountModels', () => { ]); }); + it('counts the reachable share of a picked set per agent, untagged models included', () => { + const gateway = account('acc_gw', { + service: 'linkcode-gateway', + credential: { type: 'auth-token', token: 'lc-test' }, + models: [ + { id: 'openai/gpt-5.6', protocols: ['openai-chat', 'openai-responses'] }, + { id: 'openai/gpt-4.1', protocols: ['openai-chat', 'openai-responses'] }, + { id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat', 'anthropic'] }, + ], + }); + // The same picked set is a different share per agent: codex binds responses, claude-code the + // gateway's Anthropic wire. + expect(accountModelReach(gateway, 'codex')).toEqual({ picked: 3, reachable: 2 }); + expect(accountModelReach(gateway, 'claude-code')).toEqual({ picked: 3, reachable: 1 }); + // An untagged set predates the protocol probe, so it counts as fully reachable rather than + // reading as a shortfall the user cannot act on. + expect(accountModelReach(account('acc_old', { models: [{ id: 'x-1' }] }), 'codex')).toEqual({ + picked: 1, + reachable: 1, + }); + expect(accountModelReach(account('acc_empty'), 'codex')).toEqual({ picked: 0, reachable: 0 }); + }); + it('reports an account with no picked model as offering nothing, not as unavailable', () => { expect(enabledAccountModels([account('acc_empty')], {}, 'opencode')).toEqual([]); expect(accountEnabledFor({}, 'opencode', 'acc_empty')).toBe(true); diff --git a/packages/foundation/providers/src/enabled-models.ts b/packages/foundation/providers/src/enabled-models.ts index 0bff32059..c31ece2f3 100644 --- a/packages/foundation/providers/src/enabled-models.ts +++ b/packages/foundation/providers/src/enabled-models.ts @@ -46,6 +46,26 @@ function modelReachable(model: AccountModel, protocol: AccountProtocol | undefin ); } +/** How much of an account's picked set one agent can actually run: `picked` is the stored set, + * `reachable` the part the protocol this agent binds answers. Counting rather than filtering is + * what Settings needs — it names the shortfall next to the agent's own switch, and a set with no + * `protocols` at all counts as fully reachable, matching `enabledAccountModels` and keeping an + * un-probed account silent instead of alarming. */ +export function accountModelReach( + account: Account, + kind: AgentKind, +): { picked: number; reachable: number } { + const models = account.models ?? []; + const protocol = boundProtocol(resolveBinding(account, kind)); + return { + picked: models.length, + reachable: models.reduce( + (count, model) => (modelReachable(model, protocol) ? count + 1 : count), + 0, + ), + }; +} + function resolvedAccounts( accounts: Accounts, providers: ProvidersConfig | undefined, diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index 6204a511f..80513b045 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -17,7 +17,12 @@ export { CURATED_AGENT_MODELS } from './curated-models'; export type { DetectedLogin } from './detected-logins'; export { detectedLogins } from './detected-logins'; export type { EnabledAccountModel } from './enabled-models'; -export { accountEnabledFor, enabledAccountModels, enabledAccounts } from './enabled-models'; +export { + accountEnabledFor, + accountModelReach, + enabledAccountModels, + enabledAccounts, +} from './enabled-models'; export type { BindingTier, BindingUnavailableReason, ResolvedBinding } from './resolve'; export { pinnedEndpoint, resolveBinding, serviceProtocols } from './resolve'; export { fillTemplate, isTemplateFilled, templatePlaceholders } from './template'; diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index 02a181740..44ab86677 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -1102,6 +1102,8 @@ export const en = { unavailableOauth: 'Only connects to {agent}', unavailableProtocol: 'The endpoint protocol is incompatible with this agent', unavailableEndpointIncomplete: 'Endpoint details are incomplete — finish the account setup', + noReachableModel: 'None of the selected models speaks the protocol this agent uses', + modelsReachable: '{reachable} of {picked} selected models work with this agent', configPreview: 'config.json snippet · what this account writes', configPreviewEmpty: '// not connected to any agent yet', remove: 'Remove account', diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 68d11d116..2356dc5d1 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -1074,6 +1074,8 @@ export const zhCN = { unavailableOauth: '仅可接入 {agent}', unavailableProtocol: '端点协议与此智能体不兼容', unavailableEndpointIncomplete: '端点信息不完整,请补全账号设置', + noReachableModel: '选中的模型都不支持此智能体使用的协议', + modelsReachable: '选中的 {picked} 个模型中有 {reachable} 个可用于此智能体', configPreview: 'config.json 片段 · 此账号写入的内容', configPreviewEmpty: '// 尚未接入任何智能体', remove: '移除账号', diff --git a/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx new file mode 100644 index 000000000..10645fc84 --- /dev/null +++ b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import type { AgentKind } from '@linkcode/schema'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + ProviderAccountDetailViewModel, + ProviderAgentViewModel, +} from '../providers/account-detail'; +import { AccountDetail } from '../providers/account-detail'; + +function passthrough(key: string, values?: Record): string { + const interpolation = values ? Object.values(values).join(',') : ''; + return interpolation ? `${key}:${interpolation}` : key; +} + +vi.mock('use-intl', () => ({ useTranslations: () => passthrough })); + +afterEach(cleanup); + +const REACHABLE_PATTERN = /modelsReachable/; + +function detail(agents: ProviderAgentViewModel[]): ProviderAccountDetailViewModel { + const bound = agents.reduce((kinds, agent) => { + if (agent.enabled) kinds.push(agent.kind); + return kinds; + }, []); + return { + id: 'acc_gw', + label: 'LinkCode Gateway', + credential: { kind: 'secret', type: 'auth-token', value: 'lc-secret', maskedValue: 'lc-…ret' }, + agents, + boundAgents: bound, + enabledAgentCount: bound.length, + availableAgentCount: agents.filter(({ tier }) => tier !== 'unavailable').length, + }; +} + +function renderDetail(agent: ProviderAgentViewModel): void { + render( + , + ); +} + +describe('AccountDetail agent rows', () => { + it('names the reachable share only when the agent cannot run the whole picked set', () => { + renderDetail({ + kind: 'codex', + tier: 'native', + enabled: true, + models: { picked: 3, reachable: 2 }, + }); + expect(screen.getByText('modelsReachable:2,3')).toBeTruthy(); + }); + + it('stays silent when every picked model runs on this agent', () => { + renderDetail({ + kind: 'codex', + tier: 'native', + enabled: true, + models: { picked: 3, reachable: 3 }, + }); + expect(screen.queryByText(REACHABLE_PATTERN)).toBeNull(); + }); + + it('says why an enabled agent offers nothing instead of leaving the switch to imply it', () => { + renderDetail({ + kind: 'codex', + tier: 'native', + enabled: true, + models: { picked: 3, reachable: 0 }, + status: { kind: 'no-reachable-model' }, + }); + expect(screen.getByText('noReachableModel')).toBeTruthy(); + // Zero reachable is the status' story; a "0 of 3" count next to it would say it twice. + expect(screen.queryByText(REACHABLE_PATTERN)).toBeNull(); + }); +}); diff --git a/packages/presentation/ui/src/shell/providers/account-detail.tsx b/packages/presentation/ui/src/shell/providers/account-detail.tsx index aa7f08dde..9630b4584 100644 --- a/packages/presentation/ui/src/shell/providers/account-detail.tsx +++ b/packages/presentation/ui/src/shell/providers/account-detail.tsx @@ -34,6 +34,7 @@ export type ProviderAgentStatus = | { kind: 'unavailable-oauth'; agent: AgentKind } | { kind: 'unavailable-endpoint-incomplete' } | { kind: 'unavailable-protocol' } + | { kind: 'no-reachable-model' } | { kind: 'disabled' }; /** One agent row in an account's dialog: whether this account's models are offered to that agent. @@ -43,6 +44,11 @@ export interface ProviderAgentViewModel { tier: 'native' | 'translate' | 'unavailable'; /** Only a reason the row cannot be, or is not, on. Absent means enabled and available. */ status?: ProviderAgentStatus; + /** How much of the account's picked set this agent can run — absent when it picked none. Only a + * shortfall is worth showing: the protocols one agent speaks are not every protocol the account's + * models answer, and a model this agent cannot reach is missing from its picker for that reason + * alone. */ + models?: { picked: number; reachable: number }; enabled: boolean; } @@ -314,6 +320,8 @@ function agentStatusLabel( return t('unavailableEndpointIncomplete'); case 'unavailable-protocol': return t('unavailableProtocol'); + case 'no-reachable-model': + return t('noReachableModel'); case 'disabled': return t('accountDisabled'); default: @@ -336,8 +344,15 @@ function AgentRow({ const unavailable = agent.tier === 'unavailable'; const status = agent.status && agentStatusLabel(t, tAgent, agent.status); - const note = - agent.tier === 'translate' ? [t('translateNote'), status].filter(Boolean).join(' · ') : status; + const reach = + agent.models !== undefined && + agent.models.reachable > 0 && + agent.models.reachable < agent.models.picked + ? t('modelsReachable', { reachable: agent.models.reachable, picked: agent.models.picked }) + : undefined; + const note = [agent.tier === 'translate' ? t('translateNote') : undefined, status, reach] + .filter(Boolean) + .join(' · '); return (
Date: Fri, 11 Sep 2026 10:56:52 +0800 Subject: [PATCH 2/3] refactor(providers): name the model-reach type, decide the shortfall once The rule for when a share is worth showing sat half in the view model and half in the row that renders it, so the two could drift apart. --- .../settings/providers/__tests__/view.test.ts | 19 ++++++++----- .../workbench/src/settings/providers/view.ts | 10 +++---- .../providers/src/enabled-models.ts | 27 ++++++++++++------- packages/foundation/providers/src/index.ts | 2 +- .../shell/__tests__/account-detail.test.tsx | 26 +++++++++--------- .../ui/src/shell/providers/account-detail.tsx | 18 +++++-------- 6 files changed, 54 insertions(+), 48 deletions(-) diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index 5664ca14c..d3abb2e32 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -159,16 +159,17 @@ describe('view helpers', () => { id: 'anthropic/claude-sonnet-5', protocols: ['openai-chat', 'anthropic'], }; + const responsesToo: AccountModel = { + id: 'openai/gpt-5.6', + protocols: ['openai-chat', 'openai-responses'], + }; const gateway: Accounts[number] = { id: 'acc_gw', label: 'LinkCode Gateway', createdAt: 0, service: 'linkcode-gateway', credential: { type: 'auth-token', token: 'lc-test' }, - models: [ - { id: 'openai/gpt-5.6', protocols: ['openai-chat', 'openai-responses'] }, - claudeOnly, - ], + models: [responsesToo, claudeOnly], }; const codexRow = (account: Accounts[number]) => providerAccountDetailViewModel(account, undefined, undefined).agents.find( @@ -176,15 +177,19 @@ describe('view helpers', () => { ); const shortfall = codexRow(gateway); - expect(shortfall?.models).toEqual({ picked: 2, reachable: 1 }); + expect(shortfall?.modelShortfall).toEqual({ picked: 2, reachable: 1 }); // A shortfall is not a reason the row is off, so it carries no status of its own. expect(shortfall?.status).toBeUndefined(); - // Pick only what codex cannot reach and the row has to say why its picker is empty. + // Pick only what codex cannot reach and the row has to say why its picker is empty — as a + // sentence, not as a "0 of 1" ratio saying the same thing twice. const empty = codexRow({ ...gateway, models: [claudeOnly] }); expect(empty?.enabled).toBe(true); - expect(empty?.models).toEqual({ picked: 1, reachable: 0 }); expect(empty?.status).toEqual({ kind: 'no-reachable-model' }); + expect(empty?.modelShortfall).toBeUndefined(); + + // Nothing to report when the agent can run everything that was picked. + expect(codexRow({ ...gateway, models: [responsesToo] })?.modelShortfall).toBeUndefined(); }); it('updates editable account fields without replacing its identity or hidden fields', () => { diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 604ee404e..04f277e92 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -1,3 +1,4 @@ +import type { AccountModelReach } from '@linkcode/providers'; import { accountEnabledFor, accountModelReach, @@ -120,16 +121,15 @@ function agentStatus( return { tier: availability.tier, enabled, - ...(models.picked > 0 && { models }), + // Absent unless there is a shortfall to name: whether a count is worth showing is one decision, + // and it belongs beside the status it shares a rule with, not in the component that renders it. + ...(models.reachable > 0 && models.reachable < models.picked && { modelShortfall: models }), ...(status !== undefined && { status }), }; } /** Why an available agent offers nothing, when that is not obvious from its switch. */ -function offerStatus( - enabled: boolean, - models: { picked: number; reachable: number }, -): ProviderAgentStatus | undefined { +function offerStatus(enabled: boolean, models: AccountModelReach): ProviderAgentStatus | undefined { if (!enabled) return { kind: 'disabled' }; // An enabled agent whose picker comes up empty reads as an enablement bug; the picked set is the // real reason, so the row names it instead of leaving the switch to imply otherwise. diff --git a/packages/foundation/providers/src/enabled-models.ts b/packages/foundation/providers/src/enabled-models.ts index c31ece2f3..5c968c2a1 100644 --- a/packages/foundation/providers/src/enabled-models.ts +++ b/packages/foundation/providers/src/enabled-models.ts @@ -46,15 +46,24 @@ function modelReachable(model: AccountModel, protocol: AccountProtocol | undefin ); } -/** How much of an account's picked set one agent can actually run: `picked` is the stored set, - * `reachable` the part the protocol this agent binds answers. Counting rather than filtering is - * what Settings needs — it names the shortfall next to the agent's own switch, and a set with no - * `protocols` at all counts as fully reachable, matching `enabledAccountModels` and keeping an - * un-probed account silent instead of alarming. */ -export function accountModelReach( - account: Account, - kind: AgentKind, -): { picked: number; reachable: number } { +/** How much of an account's picked set one agent can run: `picked` is the stored set, `reachable` + * the part the protocol this agent binds answers. */ +export interface AccountModelReach { + picked: number; + reachable: number; +} + +/** + * The counted form of `enabledAccountModels`' own narrowing, for the Settings row that names a + * shortfall next to the agent's switch. A set with no `protocols` at all counts as fully reachable, + * matching that filter, so an account probed before tagging existed stays silent instead of + * alarming. + * + * **Only meaningful for a binding that resolves.** An unavailable agent has no protocol to count + * against and reads as fully reachable — ask `resolveBinding` first, as `agentStatus` does, and + * report the unavailability itself rather than this count. + */ +export function accountModelReach(account: Account, kind: AgentKind): AccountModelReach { const models = account.models ?? []; const protocol = boundProtocol(resolveBinding(account, kind)); return { diff --git a/packages/foundation/providers/src/index.ts b/packages/foundation/providers/src/index.ts index 80513b045..ee1e6b6da 100644 --- a/packages/foundation/providers/src/index.ts +++ b/packages/foundation/providers/src/index.ts @@ -16,7 +16,7 @@ export { export { CURATED_AGENT_MODELS } from './curated-models'; export type { DetectedLogin } from './detected-logins'; export { detectedLogins } from './detected-logins'; -export type { EnabledAccountModel } from './enabled-models'; +export type { AccountModelReach, EnabledAccountModel } from './enabled-models'; export { accountEnabledFor, accountModelReach, diff --git a/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx index 10645fc84..75cfcc9a2 100644 --- a/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx @@ -9,9 +9,11 @@ import type { } from '../providers/account-detail'; import { AccountDetail } from '../providers/account-detail'; +/** Names each value, so an assertion says which number it expected where. */ function passthrough(key: string, values?: Record): string { - const interpolation = values ? Object.values(values).join(',') : ''; - return interpolation ? `${key}:${interpolation}` : key; + if (!values) return key; + const named = Object.entries(values).map(([name, value]) => `${name}=${String(value)}`); + return `${key}:${named.join(',')}`; } vi.mock('use-intl', () => ({ useTranslations: () => passthrough })); @@ -49,23 +51,20 @@ function renderDetail(agent: ProviderAgentViewModel): void { } describe('AccountDetail agent rows', () => { - it('names the reachable share only when the agent cannot run the whole picked set', () => { + it('names the share it was handed, each number under its own placeholder', () => { renderDetail({ kind: 'codex', tier: 'native', enabled: true, - models: { picked: 3, reachable: 2 }, + modelShortfall: { picked: 3, reachable: 2 }, }); - expect(screen.getByText('modelsReachable:2,3')).toBeTruthy(); + expect(screen.getByText('modelsReachable:picked=3,reachable=2')).toBeTruthy(); }); - it('stays silent when every picked model runs on this agent', () => { - renderDetail({ - kind: 'codex', - tier: 'native', - enabled: true, - models: { picked: 3, reachable: 3 }, - }); + // Whether a share is worth naming is the view model's call (see the workbench view tests); the + // row's own rule is only that an absent one says nothing at all. + it('says nothing when it was handed no share', () => { + renderDetail({ kind: 'codex', tier: 'native', enabled: true }); expect(screen.queryByText(REACHABLE_PATTERN)).toBeNull(); }); @@ -74,11 +73,10 @@ describe('AccountDetail agent rows', () => { kind: 'codex', tier: 'native', enabled: true, - models: { picked: 3, reachable: 0 }, status: { kind: 'no-reachable-model' }, }); expect(screen.getByText('noReachableModel')).toBeTruthy(); - // Zero reachable is the status' story; a "0 of 3" count next to it would say it twice. + // Zero reachable is the status' story; a "0 of 3" ratio beside it would say it twice. expect(screen.queryByText(REACHABLE_PATTERN)).toBeNull(); }); }); diff --git a/packages/presentation/ui/src/shell/providers/account-detail.tsx b/packages/presentation/ui/src/shell/providers/account-detail.tsx index 9630b4584..7bb23ced3 100644 --- a/packages/presentation/ui/src/shell/providers/account-detail.tsx +++ b/packages/presentation/ui/src/shell/providers/account-detail.tsx @@ -44,11 +44,10 @@ export interface ProviderAgentViewModel { tier: 'native' | 'translate' | 'unavailable'; /** Only a reason the row cannot be, or is not, on. Absent means enabled and available. */ status?: ProviderAgentStatus; - /** How much of the account's picked set this agent can run — absent when it picked none. Only a - * shortfall is worth showing: the protocols one agent speaks are not every protocol the account's - * models answer, and a model this agent cannot reach is missing from its picker for that reason - * alone. */ - models?: { picked: number; reachable: number }; + /** A picked set this agent can run only part of, already judged worth naming — absent means + * nothing to say, never "no models". Zero reachable arrives as a `status` instead, since that one + * needs a sentence rather than a ratio. */ + modelShortfall?: { picked: number; reachable: number }; enabled: boolean; } @@ -344,13 +343,8 @@ function AgentRow({ const unavailable = agent.tier === 'unavailable'; const status = agent.status && agentStatusLabel(t, tAgent, agent.status); - const reach = - agent.models !== undefined && - agent.models.reachable > 0 && - agent.models.reachable < agent.models.picked - ? t('modelsReachable', { reachable: agent.models.reachable, picked: agent.models.picked }) - : undefined; - const note = [agent.tier === 'translate' ? t('translateNote') : undefined, status, reach] + const shortfall = agent.modelShortfall && t('modelsReachable', agent.modelShortfall); + const note = [agent.tier === 'translate' ? t('translateNote') : undefined, status, shortfall] .filter(Boolean) .join(' · '); From 3bf358bceda79c5280b38a01c8de97534e8b1b74 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Fri, 11 Sep 2026 11:08:17 +0800 Subject: [PATCH 3/3] refactor(ui): fold the model shortfall into the agent row's one status Two optional fields for facts that never stack let a row claim to be both off and short of models; the union now admits one answer at a time. --- .../settings/providers/__tests__/view.test.ts | 8 ++------ .../workbench/src/settings/providers/view.ts | 17 ++++++++++------- .../shell/__tests__/account-detail.test.tsx | 8 ++++---- .../ui/src/shell/providers/account-detail.tsx | 18 +++++++++--------- 4 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts index d3abb2e32..1a855c799 100644 --- a/packages/client/workbench/src/settings/providers/__tests__/view.test.ts +++ b/packages/client/workbench/src/settings/providers/__tests__/view.test.ts @@ -176,20 +176,16 @@ describe('view helpers', () => { ({ kind }) => kind === 'codex', ); - const shortfall = codexRow(gateway); - expect(shortfall?.modelShortfall).toEqual({ picked: 2, reachable: 1 }); - // A shortfall is not a reason the row is off, so it carries no status of its own. - expect(shortfall?.status).toBeUndefined(); + expect(codexRow(gateway)?.status).toEqual({ kind: 'model-shortfall', picked: 2, reachable: 1 }); // Pick only what codex cannot reach and the row has to say why its picker is empty — as a // sentence, not as a "0 of 1" ratio saying the same thing twice. const empty = codexRow({ ...gateway, models: [claudeOnly] }); expect(empty?.enabled).toBe(true); expect(empty?.status).toEqual({ kind: 'no-reachable-model' }); - expect(empty?.modelShortfall).toBeUndefined(); // Nothing to report when the agent can run everything that was picked. - expect(codexRow({ ...gateway, models: [responsesToo] })?.modelShortfall).toBeUndefined(); + expect(codexRow({ ...gateway, models: [responsesToo] })?.status).toBeUndefined(); }); it('updates editable account fields without replacing its identity or hidden fields', () => { diff --git a/packages/client/workbench/src/settings/providers/view.ts b/packages/client/workbench/src/settings/providers/view.ts index 04f277e92..84812f695 100644 --- a/packages/client/workbench/src/settings/providers/view.ts +++ b/packages/client/workbench/src/settings/providers/view.ts @@ -116,24 +116,27 @@ function agentStatus( } // Enabled is the whole state, and the switch already shows it — only a reason to be off earns text. const enabled = accountEnabledFor(providers, kind, account.id); - const models = accountModelReach(account, kind); - const status = offerStatus(enabled, models); + const status = offerStatus(enabled, accountModelReach(account, kind)); return { tier: availability.tier, enabled, - // Absent unless there is a shortfall to name: whether a count is worth showing is one decision, - // and it belongs beside the status it shares a rule with, not in the component that renders it. - ...(models.reachable > 0 && models.reachable < models.picked && { modelShortfall: models }), ...(status !== undefined && { status }), }; } -/** Why an available agent offers nothing, when that is not obvious from its switch. */ +/** + * What this row has to say for itself, most operative first: an off switch outranks anything about + * the models behind it, and an empty picker is a sentence rather than a "0 of 3" ratio. Returning + * one of them — rather than setting a field per fact — is what keeps the row from rendering two + * answers to the same question. + */ function offerStatus(enabled: boolean, models: AccountModelReach): ProviderAgentStatus | undefined { if (!enabled) return { kind: 'disabled' }; + if (models.picked === 0 || models.reachable === models.picked) return undefined; // An enabled agent whose picker comes up empty reads as an enablement bug; the picked set is the // real reason, so the row names it instead of leaving the switch to imply otherwise. - return models.picked > 0 && models.reachable === 0 ? { kind: 'no-reachable-model' } : undefined; + if (models.reachable === 0) return { kind: 'no-reachable-model' }; + return { kind: 'model-shortfall', ...models }; } /** Selected account plus precomputed binding rows; UI owns only rendering and local interaction. */ diff --git a/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx index 75cfcc9a2..b7500d93b 100644 --- a/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx +++ b/packages/presentation/ui/src/shell/__tests__/account-detail.test.tsx @@ -56,14 +56,14 @@ describe('AccountDetail agent rows', () => { kind: 'codex', tier: 'native', enabled: true, - modelShortfall: { picked: 3, reachable: 2 }, + status: { kind: 'model-shortfall', picked: 3, reachable: 2 }, }); expect(screen.getByText('modelsReachable:picked=3,reachable=2')).toBeTruthy(); }); - // Whether a share is worth naming is the view model's call (see the workbench view tests); the - // row's own rule is only that an absent one says nothing at all. - it('says nothing when it was handed no share', () => { + // Which one of these a row gets is the view model's call (see the workbench view tests); the + // row's own rule is only that an absent status says nothing at all. + it('says nothing when it was handed no status', () => { renderDetail({ kind: 'codex', tier: 'native', enabled: true }); expect(screen.queryByText(REACHABLE_PATTERN)).toBeNull(); }); diff --git a/packages/presentation/ui/src/shell/providers/account-detail.tsx b/packages/presentation/ui/src/shell/providers/account-detail.tsx index 7bb23ced3..ae68544e8 100644 --- a/packages/presentation/ui/src/shell/providers/account-detail.tsx +++ b/packages/presentation/ui/src/shell/providers/account-detail.tsx @@ -35,6 +35,7 @@ export type ProviderAgentStatus = | { kind: 'unavailable-endpoint-incomplete' } | { kind: 'unavailable-protocol' } | { kind: 'no-reachable-model' } + | { kind: 'model-shortfall'; picked: number; reachable: number } | { kind: 'disabled' }; /** One agent row in an account's dialog: whether this account's models are offered to that agent. @@ -42,12 +43,11 @@ export type ProviderAgentStatus = export interface ProviderAgentViewModel { kind: AgentKind; tier: 'native' | 'translate' | 'unavailable'; - /** Only a reason the row cannot be, or is not, on. Absent means enabled and available. */ + /** The one thing worth saying about this row — a reason it cannot be, or is not, on, or a picked + * set it can run only part of. Absent means nothing to say. One field rather than several, + * because these never stack: the view model picks which one applies, so the row cannot render + * "off" and "2 of 3 models" as if both were the news. */ status?: ProviderAgentStatus; - /** A picked set this agent can run only part of, already judged worth naming — absent means - * nothing to say, never "no models". Zero reachable arrives as a `status` instead, since that one - * needs a sentence rather than a ratio. */ - modelShortfall?: { picked: number; reachable: number }; enabled: boolean; } @@ -321,6 +321,8 @@ function agentStatusLabel( return t('unavailableProtocol'); case 'no-reachable-model': return t('noReachableModel'); + case 'model-shortfall': + return t('modelsReachable', { picked: status.picked, reachable: status.reachable }); case 'disabled': return t('accountDisabled'); default: @@ -343,10 +345,8 @@ function AgentRow({ const unavailable = agent.tier === 'unavailable'; const status = agent.status && agentStatusLabel(t, tAgent, agent.status); - const shortfall = agent.modelShortfall && t('modelsReachable', agent.modelShortfall); - const note = [agent.tier === 'translate' ? t('translateNote') : undefined, status, shortfall] - .filter(Boolean) - .join(' · '); + const note = + agent.tier === 'translate' ? [t('translateNote'), status].filter(Boolean).join(' · ') : status; return (