diff --git a/web/features/agent-v2/agent-composer/provider.tsx b/web/features/agent-v2/agent-composer/provider.tsx index 2822ee37260..54812b73307 100644 --- a/web/features/agent-v2/agent-composer/provider.tsx +++ b/web/features/agent-v2/agent-composer/provider.tsx @@ -9,9 +9,11 @@ import { agentComposerDraftAtom, agentComposerSavedDraftAtom } from './store' export function AgentComposerProvider({ children, initialDraft, + initialSavedDraft = initialDraft, }: { children: ReactNode initialDraft?: AgentSoulConfigFormState + initialSavedDraft?: AgentSoulConfigFormState }) { const draft = initialDraft ?? defaultAgentSoulConfigFormState @@ -19,7 +21,7 @@ export function AgentComposerProvider({ diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index c6597e09d95..1365fe704cf 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -150,6 +150,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => { useQuery: vi.fn((options: { queryKey?: readonly string[] }) => { const queryKey = options.queryKey?.[0] + if (queryKey === 'default-model') { + return { data: { data: modelHooksState.defaultTextGenerationModel }, isPending: false } + } if (queryKey === 'agent') return mocks.queryState.agent if (queryKey === 'composer') return mocks.queryState.composer if (queryKey === 'version') return mocks.queryState.version @@ -212,6 +215,13 @@ vi.mock('@/service/console', () => ({ queryOptions: () => ({ queryKey: ['system-features'] }), }, }, + workspaces: { + current: { + defaultModel: { + get: { queryOptions: () => ({ queryKey: ['default-model'] }) }, + }, + }, + }, agent: { get: { key: () => ['agents'], @@ -317,6 +327,7 @@ vi.mock('../components/orchestrate', async () => { return { AgentOrchestratePanel: (props: { bottomAction?: ReactNode + currentModel?: { model: string } isBuildDraftActive?: boolean onExitVersions?: () => void onOpenVersions?: () => void @@ -333,6 +344,7 @@ vi.mock('../components/orchestrate', async () => { {`readonly:${props.readOnly ? 'yes' : 'no'}`} {`publish:${props.showPublishBar ? 'yes' : 'no'}`} {`prompt:${prompt}`} + {`model:${props.currentModel?.model ?? 'none'}`} @@ -2340,7 +2352,62 @@ describe('AgentConfigurePage', () => { expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled() }) - it('should require an explicit model even when the workspace has a default', async () => { + it('should save the workspace default model before starting a build', async () => { + const queryClient = createQueryClient() + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: undefined as unknown, + dataUpdatedAt: 0, + error: new Response(null, { status: 404 }), + isFetching: false, + isError: true, + isPending: false, + isSuccess: false, + refetch: vi.fn(), + } + + render( + + + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'send build message' })) + + await waitFor(() => { + expect(mocks.checkoutBuildDraft).toHaveBeenCalled() + }) + expect(mocks.saveComposerDraft).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + model: expect.objectContaining({ + model_provider: 'langgenius/openai/openai', + model: 'gpt-4o-mini', + }), + }), + }), + }), + expect.anything(), + ) + expectFirstMockCallBefore(mocks.saveComposerDraft, mocks.checkoutBuildDraft) + }) + + it('should require a model when neither the draft nor the workspace has one', async () => { + modelHooksState.defaultTextGenerationModel = undefined const queryClient = createQueryClient() mocks.queryState.composer = { data: { @@ -3380,6 +3447,9 @@ describe('AgentConfigurePage', () => { , ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'model:none', + ) await user.click(screen.getByRole('button', { name: 'discard build draft' })) await waitFor(() => @@ -3401,6 +3471,11 @@ describe('AgentConfigurePage', () => { expect.any(Object), ) expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none') + await waitFor(() => { + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'model:gpt-4o-mini', + ) + }) }) it('should keep exiting build draft when debug conversation refresh fails after discarding build draft', async () => { diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index e9c22835a37..c8cfdf01181 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -13,6 +13,7 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo import { agentComposerDraftAtom, agentComposerSavedDraftAtom, + isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' @@ -240,6 +241,7 @@ function renderUseAgentConfigureSync({ agentName = 'Agent', baseConfig, currentModel, + savedDraft, enabled = true, publishEnabled, suspend = false, @@ -247,6 +249,7 @@ function renderUseAgentConfigureSync({ agentName?: Parameters[0]['agentName'] baseConfig?: Parameters[0]['baseConfig'] currentModel?: AgentSoulConfigFormState['model'] + savedDraft?: AgentSoulConfigFormState enabled?: boolean publishEnabled?: boolean suspend?: boolean @@ -266,7 +269,7 @@ function renderUseAgentConfigureSync({ ? { ...defaultAgentSoulConfigFormState, model: currentModel } : defaultAgentSoulConfigFormState store.set(agentComposerDraftAtom, initialDraft) - store.set(agentComposerSavedDraftAtom, initialDraft) + store.set(agentComposerSavedDraftAtom, savedDraft ?? initialDraft) const pendingRender = new Promise(() => {}) const wrapper = ({ children }: PropsWithChildren) => ( @@ -499,6 +502,105 @@ describe('useAgentConfigureSync', () => { ) }) + it('autosaves an initialized default model without another edit', async () => { + const { store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + savedDraft: defaultAgentSoulConfigFormState, + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(composerPutMutationFn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + model: expect.objectContaining({ + model_provider: configuredModel.provider, + model: configuredModel.model, + }), + }), + }), + }), + ) + expect(store.get(isAgentComposerDirtyAtom)).toBe(false) + }) + + it('serializes a newly selected model after an in-flight default model save', async () => { + const savingDefault = createDeferredPromise<{ agent_soul: Record }>() + composerPutMutationFn.mockReturnValueOnce(savingDefault.promise) + const { store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + savedDraft: defaultAgentSoulConfigFormState, + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + + const selectedModel = { ...configuredModel, model: 'gpt-4o' } + act(() => { + store.set(agentComposerDraftAtom, { + ...store.get(agentComposerDraftAtom), + model: selectedModel, + }) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + + const savingSelection = createDeferredPromise<{ agent_soul: Record }>() + composerPutMutationFn.mockReturnValueOnce(savingSelection.promise) + await act(async () => { + savingDefault.resolve({ agent_soul: {} }) + }) + + expect(store.get(agentComposerDraftAtom).model).toEqual(selectedModel) + expect(store.get(isAgentComposerDirtyAtom)).toBe(true) + expect(composerPutMutationFn).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + model: expect.objectContaining({ model: selectedModel.model }), + }), + }), + }), + ) + + await act(async () => { + savingSelection.resolve({ agent_soul: {} }) + }) + expect(store.get(agentComposerDraftAtom).model).toEqual(selectedModel) + expect(store.get(isAgentComposerDirtyAtom)).toBe(false) + }) + + it('autosaves an existing dirty model when saving becomes enabled', async () => { + const { rerender } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + savedDraft: defaultAgentSoulConfigFormState, + enabled: false, + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(composerPutMutationFn).not.toHaveBeenCalled() + + rerender({ + agentName: 'Agent', + baseConfig: undefined, + currentModel: configuredModel, + enabled: true, + suspend: false, + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + }) + it('should cancel pending autosave when the draft returns to the saved baseline', async () => { const { queryClient, store } = renderUseAgentConfigureSync() queryClient.setQueryData(['agent-detail', 'agent-1'], { diff --git a/web/features/agent-v2/agent-detail/configure/components/__tests__/composer-provider.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/__tests__/composer-provider.spec.tsx new file mode 100644 index 00000000000..ba376559752 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/__tests__/composer-provider.spec.tsx @@ -0,0 +1,170 @@ +import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { + DefaultModelDataResponse, + SimpleProviderEntityResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useAtom, useAtomValue } from 'jotai' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { + agentComposerSavedDraftAtom, + isAgentComposerDirtyAtom, +} from '@/features/agent-v2/agent-composer/store' +import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' +import { AgentConfigureComposerProvider } from '../composer-provider' + +const mocks = vi.hoisted(() => ({ + getDefaultModel: vi.fn<() => Promise>(), +})) + +vi.mock('@/service/console', () => ({ + consoleQuery: { + workspaces: { + current: { + defaultModel: { + get: { + queryOptions: ({ enabled }: { enabled: boolean }) => ({ + queryKey: ['default-model'], + queryFn: mocks.getDefaultModel, + enabled, + }), + }, + }, + }, + }, + }, +})) + +function ModelEditor() { + const [model, setModel] = useAtom(agentComposerModelAtom) + const savedDraft = useAtomValue(agentComposerSavedDraftAtom) + const isDirty = useAtomValue(isAgentComposerDirtyAtom) + + return ( +
+ + {savedDraft?.model?.model ?? 'None'} + {String(isDirty)} +
+ ) +} + +function renderComposer(initialConfig: AgentSoulConfig = {}, initializeDefaultModel = true) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + , + ) + return queryClient +} + +const provider: SimpleProviderEntityResponse = { + provider: 'langgenius/openai/openai', + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + supported_model_types: ['llm'], + tenant_id: 'workspace-1', +} + +const defaultModel: DefaultModelDataResponse = { + data: { model: 'gpt-4o-mini', model_type: 'llm', provider }, +} + +describe('Agent configure default model initialization', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getDefaultModel.mockResolvedValue(defaultModel) + }) + + it('waits for the default before exposing an editable draft and preserves the unsaved baseline', async () => { + let resolve!: (value: DefaultModelDataResponse) => void + mocks.getDefaultModel.mockReturnValue( + new Promise((promiseResolve) => { + resolve = promiseResolve + }), + ) + renderComposer() + + expect(screen.queryByRole('textbox', { name: 'Model' })).not.toBeInTheDocument() + + await act(async () => resolve(defaultModel)) + + expect(await screen.findByRole('textbox', { name: 'Model' })).toHaveValue('gpt-4o-mini') + expect(screen.getByLabelText('Saved model')).toHaveTextContent('None') + expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('true') + }) + + it('preserves an explicitly configured model without fetching the default', () => { + renderComposer({ + model: { + model: 'configured-model', + model_provider: 'openai', + plugin_id: 'langgenius/openai', + }, + }) + + expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('configured-model') + expect(screen.getByLabelText('Saved model')).toHaveTextContent('configured-model') + expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('false') + expect(mocks.getDefaultModel).not.toHaveBeenCalled() + }) + + it('leaves model-less configurations unchanged when initialization is disabled', () => { + renderComposer({}, false) + + expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('') + expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('false') + expect(mocks.getDefaultModel).not.toHaveBeenCalled() + }) + + it.each(['missing', 'error'] as const)( + 'allows manual selection when the default is %s', + async (result) => { + const user = userEvent.setup() + if (result === 'missing') mocks.getDefaultModel.mockResolvedValue({ data: null }) + else mocks.getDefaultModel.mockRejectedValue(new Error('Default model unavailable')) + renderComposer() + + const input = await screen.findByRole('textbox', { name: 'Model' }) + expect(input).toHaveValue('') + await user.type(input, 'manual-model') + + expect(input).toHaveValue('manual-model') + expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('true') + }, + ) + + it('preserves the user selection when another consumer refreshes the default model', async () => { + const user = userEvent.setup() + const queryClient = renderComposer() + const input = await screen.findByRole('textbox', { name: 'Model' }) + await user.clear(input) + await user.type(input, 'manual-model') + + mocks.getDefaultModel.mockResolvedValue({ + data: { model: 'new-default', model_type: 'llm', provider }, + }) + await act(async () => { + await queryClient.query({ queryKey: ['default-model'], queryFn: mocks.getDefaultModel }) + }) + + expect(input).toHaveValue('manual-model') + expect(screen.getByLabelText('Saved model')).toHaveTextContent('None') + expect(screen.getByLabelText('Unsaved changes')).toHaveTextContent('true') + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-provider.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-provider.tsx new file mode 100644 index 00000000000..d6e619837c3 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/composer-provider.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { ReactNode } from 'react' +import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions' +import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' +import { consoleQuery } from '@/service/console' +import { AgentConfigurePageLoading } from './page-loading' + +export function AgentConfigureComposerProvider({ + children, + initialConfig, + initializeDefaultModel, +}: { + children: ReactNode + initialConfig?: AgentSoulConfig + initializeDefaultModel: boolean +}) { + const { t } = useTranslation('agentV2') + const [session, setSession] = useState<{ + draft: AgentSoulConfigFormState + savedDraft: AgentSoulConfigFormState + }>() + const savedDraft = agentSoulConfigToFormState(initialConfig) + const needsDefaultModel = initializeDefaultModel && !savedDraft.model + const defaultModelQuery = useQuery( + consoleQuery.workspaces.current.defaultModel.get.queryOptions({ + input: { query: { model_type: 'llm' } }, + enabled: !session && needsDefaultModel, + }), + ) + + if (!session) { + if (needsDefaultModel && defaultModelQuery.isPending) { + return $['agentDetail.sections.configure'])} /> + } + + const defaultModel = defaultModelQuery.data?.data + // Capture defaults once for this keyed form session. Later query responses must not reset edits. + setSession({ + savedDraft, + draft: + needsDefaultModel && defaultModel + ? { + ...savedDraft, + model: { + provider: defaultModel.provider.provider, + model: defaultModel.model, + }, + } + : savedDraft, + }) + return null + } + + return ( + + {children} + + ) +} diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx index f996b9a5b83..df6542c001f 100644 --- a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx @@ -16,7 +16,6 @@ import { useTranslation } from 'react-i18next' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { toast } from '@/app/notifications' import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions' -import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' import { rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' import { @@ -39,6 +38,7 @@ import { } from '../use-agent-configure-build-draft' import { useAgentConfigureSessionController } from '../use-agent-configure-session-controller' import { useAgentConfigureSync } from '../use-agent-configure-sync' +import { AgentConfigureComposerProvider } from './composer-provider' import { AgentConfigureClearSessionConfirmDialog } from './confirm-clear-session-dialog' import { AgentOrchestratePanel } from './orchestrate' import { AgentBuildDraftBar } from './orchestrate/build-draft-bar' @@ -225,9 +225,15 @@ function AgentConfigurePageComposerSession({ ]} name="AgentConfigureConversation" > - - +
) } @@ -349,7 +355,7 @@ function AgentConfigurePageComposerContent({ }, [rebaseComposerDraft], ) - const [currentModel, setConfigureModel] = useAtom(agentComposerModelAtom) + const [currentModel, setCurrentModel] = useAtom(agentComposerModelAtom) const { isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({ agentId, agentName: agentQuery.data?.name, @@ -511,7 +517,7 @@ function AgentConfigurePageComposerContent({ /> ) : undefined } - onSelectModel={setConfigureModel} + onSelectModel={setCurrentModel} onPublish={publishDraft} onOpenVersions={() => { workingDirectoryPanel.closeWorkingDirectory() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx index 9df992fcc25..ea14afe9321 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/__tests__/field.spec.tsx @@ -3,9 +3,15 @@ import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '@/app/components/header/account-setting/model-provider-page/declarations' import { AgentModelField } from '../field' const providerSummary = vi.hoisted(() => vi.fn()) +const credentialPanelState = vi.hoisted(() => vi.fn()) const modelList = vi.hoisted(() => vi.fn<() => Promise<{ data: ProviderWithModelsResponse[] }>>()) @@ -21,7 +27,7 @@ vi.mock('@/service/use-common', () => ({ vi.mock( '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({ - useCredentialPanelState: () => ({ variant: 'api-unavailable', hasCredentials: false }), + useCredentialPanelState: credentialPanelState, }), ) @@ -75,6 +81,7 @@ describe('AgentModelField', () => { beforeEach(() => { vi.clearAllMocks() providerSummary.mockResolvedValue({ data: [] }) + credentialPanelState.mockReturnValue({ variant: 'api-unavailable', hasCredentials: false }) }) it('disables model controls until the catalog loads without selecting a model', async () => { @@ -147,6 +154,71 @@ describe('AgentModelField', () => { expect(onSelect).not.toHaveBeenCalled() }) + it.each(['compatible', 'incompatible'] as const)( + 'waits for the catalog after provider metadata before showing a %s model status', + async (compatibility) => { + const modelId = compatibility === 'compatible' ? 'gemini-3.8-flash' : 'gemini-2.0-flash' + const modelLabel = compatibility === 'compatible' ? 'Gemini 3.8 Flash' : 'Gemini 2.0 Flash' + const provider: ProviderWithModelsResponse = { + tenant_id: 'test-workspace', + provider: 'google', + label: { en_US: 'Google', zh_Hans: 'Google' }, + icon_small: { en_US: '', zh_Hans: '' }, + icon_small_dark: { en_US: '', zh_Hans: '' }, + status: ModelStatusEnum.active, + models: [ + { + model: modelId, + label: { en_US: modelLabel, zh_Hans: modelLabel }, + model_type: ModelTypeEnum.textGeneration, + features: [], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: { mode: 'chat' }, + load_balancing_enabled: false, + }, + ], + } + let resolveCatalog!: (value: { data: ProviderWithModelsResponse[] }) => void + modelList.mockImplementation( + () => + new Promise((resolve) => { + resolveCatalog = resolve + }), + ) + providerSummary.mockResolvedValue({ data: [provider] }) + credentialPanelState.mockReturnValue({ variant: 'api-active', hasCredentials: true }) + const { queryClient, onSelect } = renderField({ provider: 'google', model: modelId }) + + await waitFor(() => { + expect(queryClient.getQueryState(['providers'])?.status).toBe('success') + }) + expect(screen.getByRole('button', { name: modelId })).toBeDisabled() + expect( + screen.queryByText('common.modelProvider.selector.incompatible'), + ).not.toBeInTheDocument() + + await act(async () => { + resolveCatalog({ data: [provider] }) + }) + await waitFor(() => { + expect(screen.getByRole('button', { name: new RegExp(modelLabel) })).toBeEnabled() + }) + if (compatibility === 'compatible') { + expect(screen.getByRole('button', { name: modelLabel })).toHaveAttribute( + 'data-model-status', + 'active', + ) + expect( + screen.queryByText('common.modelProvider.selector.incompatible'), + ).not.toBeInTheDocument() + } else { + expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument() + } + expect(onSelect).not.toHaveBeenCalled() + }, + ) + it('keeps a failed initial load disabled and lets the user retry', async () => { const user = userEvent.setup() modelList.mockRejectedValueOnce(new Error('Unavailable')).mockResolvedValue({ data: [] }) diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts index c62c08ceb11..137d5821286 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts @@ -405,7 +405,13 @@ export function useAgentConfigureBuildDraftActions({ queryKey: buildDraftQueryOptions.queryKey, }) rebaseComposerDraft(nextAgentSoulConfig) - if (shouldRefetchComposer) onComposerRebased?.() + if ( + shouldRefetchComposer || + !nextAgentSoulConfig?.model?.model || + !nextAgentSoulConfig.model.model_provider + ) { + onComposerRebased?.() + } }, [ buildDraftQueryOptions.queryKey, diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts index a8a42cda89f..15eb59dfafb 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts @@ -374,7 +374,7 @@ export function useAgentConfigureSync({ ) useEffect(() => { - return store.sub(agentComposerDraftAtom, () => { + const scheduleDirtyDraftSave = () => { const agentSoulDraft = getAgentSoulDraft() const agentSoulDraftKey = JSON.stringify(agentSoulDraft) const isDirty = store.get(isAgentComposerDirtyAtom) @@ -389,8 +389,11 @@ export function useAgentConfigureSync({ } debouncedSaveDraft() - }) - }, [debouncedSaveDraft, getAgentSoulDraft, store]) + } + + scheduleDirtyDraftSave() + return store.sub(agentComposerDraftAtom, scheduleDirtyDraftSave) + }, [debouncedSaveDraft, enabled, getAgentSoulDraft, store]) useEffect(() => { const saveDraftWhenPageHidden = () => {