From 481354ca70649cdfcb1c8a853daf5a5a7d4414ff Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 27 Jul 2026 16:43:45 +0800 Subject: [PATCH] refactor: make agent composer save and publish state consistent (#39637) --- .../nodes/agent-v2/__tests__/hooks.spec.tsx | 9 +- .../nodes/agent-v2/agent-soul-config.ts | 14 +- .../agent-orchestrate-panel-content.spec.tsx | 1 - .../agent-orchestrate-panel-content.tsx | 14 +- .../__tests__/provider.spec.tsx | 40 +- .../agent-composer/__tests__/store.spec.ts | 17 +- .../agent-v2/agent-composer/provider.tsx | 14 +- web/features/agent-v2/agent-composer/store.ts | 24 +- .../configure/__tests__/page.spec.tsx | 49 +++ .../use-agent-configure-sync.spec.tsx | 382 +++++++++++++++--- .../configure/components/composer-session.tsx | 18 +- .../__tests__/publish-bar.spec.tsx | 199 ++++++--- .../files/__tests__/index.spec.tsx | 8 +- .../components/orchestrate/index.tsx | 12 - .../orchestrate/publish-bar/index.tsx | 95 +++-- .../tools/__tests__/index.spec.tsx | 6 +- .../configure/use-agent-configure-sync.ts | 271 ++++++++----- web/service/client.spec.ts | 74 +++- web/service/client.ts | 45 ++- 19 files changed, 865 insertions(+), 427 deletions(-) diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx index 95f5cb78bea..720c6cef1dd 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx @@ -4,8 +4,7 @@ import { getDefaultStore } from 'jotai' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, + agentComposerSavedDraftAtom, } from '@/features/agent-v2/agent-composer/store' import { FlowType } from '@/types/common' import { renderWorkflowHook } from '../../../__tests__/workflow-test-env' @@ -591,13 +590,11 @@ describe('useWorkflowInlineAgentConfigureSync', () => { beforeEach(() => { vi.clearAllMocks() const store = getDefaultStore() - store.set(agentComposerOriginalConfigAtom, undefined) - store.set(agentComposerOriginalDraftAtom, defaultAgentSoulConfigFormState) + store.set(agentComposerSavedDraftAtom, defaultAgentSoulConfigFormState) store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState) }) it('saves inline agent composer changes through the workflow node composer API', async () => { - vi.setSystemTime(1710000300000) const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -667,7 +664,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => { }, expect.any(Object), ) - await waitFor(() => expect(result.current.draftSavedAt).toBe(1710000300000)) expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual( expect.objectContaining({ agent_soul: expect.objectContaining({ @@ -851,7 +847,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => { expect(mockComposerMutationFn).not.toHaveBeenCalled() expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toBeUndefined() - expect(result.current.draftSavedAt).toBeUndefined() }) it('saves the effective inline model when the form draft is unchanged', async () => { diff --git a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts index 64fef972ff5..6195dbaca26 100644 --- a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts +++ b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts @@ -7,7 +7,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { debounce } from 'es-toolkit/compat' import isEqual from 'fast-deep-equal' import { useStore as useJotaiStore, useSetAtom } from 'jotai' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { agentSoulConfigToFormState, @@ -15,8 +15,7 @@ import { } from '@/features/agent-v2/agent-composer/conversions' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' import { consoleQuery } from '@/service/client' @@ -77,9 +76,7 @@ export function useWorkflowInlineAgentConfigureSync({ const queryClient = useQueryClient() const configsMap = useHooksStore((state) => state.configsMap) const store = useJotaiStore() - const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom) - const setOriginalDraft = useSetAtom(agentComposerOriginalDraftAtom) - const [draftSavedAt, setDraftSavedAt] = useState(undefined) + const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom) const baseConfigRef = useRef(baseConfig) const currentModelRef = useRef(currentModel) const enabledRef = useRef(enabled) @@ -165,9 +162,7 @@ export function useWorkflowInlineAgentConfigureSync({ composerState, ) } - setOriginalConfig(composerState.agent_soul) - setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul)) - setDraftSavedAt(Date.now()) + setSavedDraft(agentSoulConfigToFormState(composerState.agent_soul)) lastAutosavedDraftKeyRef.current = savedDraftKey onDraftSavedRef.current?.(composerState) return composerState @@ -230,7 +225,6 @@ export function useWorkflowInlineAgentConfigureSync({ }, [autoSaveEnabled, debouncedSaveDraft]) return { - draftSavedAt, saveAgentSoulConfig, saveDraft, } diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index 189112c4e66..ea410bd8635 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -183,7 +183,6 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/preview-c vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({ useWorkflowInlineAgentConfigureSync: () => ({ - draftSavedAt: undefined, saveAgentSoulConfig: mocks.saveAgentSoulConfig, saveDraft: mocks.saveDraft, }), diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx index 25e2b41bd86..0c9c753be86 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx @@ -130,10 +130,8 @@ export function WorkflowRosterAgentOrchestratePanelContent( & { - activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null agentId: string agentSoulConfig: AgentSoulConfig buildDraft: ReturnType @@ -331,7 +322,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ const { currentModel, setConfigureModel, textGenerationModelList } = useAgentOrchestrateModelOptions() const [isApplyingInlineBuildDraft, setIsApplyingInlineBuildDraft] = useState(false) - const { draftSavedAt, saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({ + const { saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({ nodeId, baseConfig: agentSoulConfig, currentModel, @@ -462,7 +453,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ (agentSoulConfig?: AgentSoulConfig) => { rebaseComposerDraft({ draft: agentSoulConfigToFormState(agentSoulConfig), - originalConfig: agentSoulConfig, }) }, [rebaseComposerDraft], @@ -688,12 +678,10 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ agentId={agentId} appId={appId} nodeId={nodeId} - activeConfigSnapshot={activeConfigSnapshot} agentSoulConfig={buildDraft.agentSoulConfig} agentName={composerState?.agent?.name} currentModel={currentModel} textGenerationModelList={textGenerationModelList} - draftSavedAt={draftSavedAt} readOnly={buildDraft.isActive} isBuildDraftActive={buildDraft.isActive} buildDraftChangedKeys={buildDraft.changedKeys} diff --git a/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx b/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx index d1fd57d8c39..e83923ace43 100644 --- a/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx +++ b/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx @@ -1,4 +1,3 @@ -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import { render, screen } from '@testing-library/react' import { useAtomValue } from 'jotai' import { describe, expect, it } from 'vitest' @@ -6,35 +5,23 @@ import { defaultAgentSoulConfigFormState } from '../form-state' import { AgentComposerProvider } from '../provider' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, - hasAgentComposerUnpublishedChangesAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '../store' function StoreSnapshot() { const draft = useAtomValue(agentComposerDraftAtom) - const originalDraft = useAtomValue(agentComposerOriginalDraftAtom) - const publishedDraft = useAtomValue(agentComposerPublishedDraftAtom) - const originalConfig = useAtomValue(agentComposerOriginalConfigAtom) + const savedDraft = useAtomValue(agentComposerSavedDraftAtom) const isDirty = useAtomValue(isAgentComposerDirtyAtom) - const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom) return (
draft
{draft.prompt}
-
original draft
-
{originalDraft?.prompt}
-
published draft
-
{publishedDraft?.prompt}
-
original config
-
{originalConfig?.prompt?.system_prompt}
+
saved draft
+
{savedDraft?.prompt}
dirty
{String(isDirty)}
-
unpublished
-
{String(hasUnpublishedChanges)}
) } @@ -49,27 +36,15 @@ describe('AgentComposerProvider', () => { ...defaultAgentSoulConfigFormState, prompt: 'Be precise.', } - const initialOriginalConfig = { - prompt: { - system_prompt: 'Be precise.', - }, - } satisfies AgentSoulConfig - render( - + , ) expect(getDefinition('draft')).toHaveTextContent('Be precise.') - expect(getDefinition('original draft')).toHaveTextContent('Be precise.') - expect(getDefinition('published draft')).toHaveTextContent('Be precise.') - expect(getDefinition('original config')).toHaveTextContent('Be precise.') + expect(getDefinition('saved draft')).toHaveTextContent('Be precise.') expect(getDefinition('dirty')).toHaveTextContent('false') - expect(getDefinition('unpublished')).toHaveTextContent('false') }) it('creates a new scoped store when the composer session key changes', () => { @@ -96,7 +71,6 @@ describe('AgentComposerProvider', () => { ) expect(getDefinition('draft')).toHaveTextContent('Agent two draft') - expect(getDefinition('original draft')).toHaveTextContent('Agent two draft') - expect(getDefinition('published draft')).toHaveTextContent('Agent two draft') + expect(getDefinition('saved draft')).toHaveTextContent('Agent two draft') }) }) diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts index c16105c6d2e..ed7615457aa 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -5,9 +5,7 @@ import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conve import { defaultAgentSoulConfigFormState } from '../form-state' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, rebaseAgentComposerDraftAtom, } from '../store' @@ -91,23 +89,12 @@ describe('agent composer store conversions', () => { ...defaultAgentSoulConfigFormState, prompt: 'Build draft prompt', } - const originalConfig = { - prompt: { - system_prompt: 'Build draft prompt', - }, - } satisfies AgentSoulConfig - store.set(rebaseAgentComposerDraftAtom, { draft: nextDraft, - originalConfig, }) expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt') - expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt') - expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt') - expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe( - 'Build draft prompt', - ) + expect(store.get(agentComposerSavedDraftAtom)?.prompt).toBe('Build draft prompt') }) it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => { diff --git a/web/features/agent-v2/agent-composer/provider.tsx b/web/features/agent-v2/agent-composer/provider.tsx index 66b6d9aa305..2822ee37260 100644 --- a/web/features/agent-v2/agent-composer/provider.tsx +++ b/web/features/agent-v2/agent-composer/provider.tsx @@ -1,35 +1,25 @@ 'use client' -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { ReactNode } from 'react' import type { AgentSoulConfigFormState } from './form-state' import { ScopeProvider } from 'jotai-scope' import { defaultAgentSoulConfigFormState } from './form-state' -import { - agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, -} from './store' +import { agentComposerDraftAtom, agentComposerSavedDraftAtom } from './store' export function AgentComposerProvider({ children, initialDraft, - initialOriginalConfig, }: { children: ReactNode initialDraft?: AgentSoulConfigFormState - initialOriginalConfig?: AgentSoulConfig }) { const draft = initialDraft ?? defaultAgentSoulConfigFormState return ( diff --git a/web/features/agent-v2/agent-composer/store.ts b/web/features/agent-v2/agent-composer/store.ts index 330db0f22ab..752ceb546e1 100644 --- a/web/features/agent-v2/agent-composer/store.ts +++ b/web/features/agent-v2/agent-composer/store.ts @@ -1,14 +1,9 @@ -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { AgentSoulConfigFormState } from './form-state' import isEqual from 'fast-deep-equal' import { atom } from 'jotai' import { defaultAgentSoulConfigFormState } from './form-state' -export const agentComposerOriginalConfigAtom = atom(undefined) -export const agentComposerOriginalDraftAtom = atom( - defaultAgentSoulConfigFormState, -) -export const agentComposerPublishedDraftAtom = atom( +export const agentComposerSavedDraftAtom = atom( defaultAgentSoulConfigFormState, ) export const agentComposerDraftAtom = atom( @@ -22,29 +17,18 @@ export const rebaseAgentComposerDraftAtom = atom( set, { draft, - originalConfig, }: { draft: AgentSoulConfigFormState - originalConfig?: AgentSoulConfig }, ) => { - set(agentComposerOriginalConfigAtom, originalConfig) set(agentComposerDraftAtom, draft) - set(agentComposerOriginalDraftAtom, draft) - set(agentComposerPublishedDraftAtom, draft) + set(agentComposerSavedDraftAtom, draft) }, ) export const isAgentComposerDirtyAtom = atom((get) => { - const originalDraft = get(agentComposerOriginalDraftAtom) + const savedDraft = get(agentComposerSavedDraftAtom) const draft = get(agentComposerDraftAtom) - return !isEqual(draft, originalDraft ?? defaultAgentSoulConfigFormState) -}) - -export const hasAgentComposerUnpublishedChangesAtom = atom((get) => { - const publishedDraft = get(agentComposerPublishedDraftAtom) - const draft = get(agentComposerDraftAtom) - - return !isEqual(draft, publishedDraft ?? defaultAgentSoulConfigFormState) + return !isEqual(draft, savedDraft ?? defaultAgentSoulConfigFormState) }) 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 c147cb2543c..4c971ea3cdb 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 @@ -710,6 +710,55 @@ describe('AgentConfigurePage', () => { ).toBeVisible() expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toBeInTheDocument() }) + + it('should initialize the composer from recovered query data after the initial request fails', () => { + const queryClient = new QueryClient() + mocks.queryState.composer = { + data: undefined as unknown, + isFetching: false, + isError: true, + isPending: false, + isSuccess: false, + refetch: vi.fn(), + } + + const view = render( + + + , + ) + + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:yes', + ) + + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'recovered draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + view.rerender( + + + , + ) + + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:recovered draft prompt', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + }) }) describe('Right panel mode', () => { 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 c5d1a13da47..7a24d7e501f 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 @@ -6,7 +6,7 @@ import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowl import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { agentComposerDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, } 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' @@ -32,9 +32,17 @@ const composerPutMutationFn = vi.hoisted(() => ), ) +const composerPutRequestContexts = vi.hoisted( + () => [] as Array<{ keepalive?: boolean; silent?: boolean } | undefined>, +) + const composerPutMutationOptions = vi.hoisted(() => vi.fn( (options?: { + context?: { + keepalive?: boolean + silent?: boolean + } onSuccess?: ( data: { agent_soul: Record }, variables: { @@ -51,6 +59,7 @@ const composerPutMutationOptions = vi.hoisted(() => agent_soul: Record } }) => { + composerPutRequestContexts.push(options?.context) const data = await composerPutMutationFn(variables) options?.onSuccess?.(data, variables) return data @@ -85,6 +94,9 @@ const publishAgentMutationFn = vi.hoisted(() => const publishAgentMutationOptions = vi.hoisted(() => vi.fn( (options?: { + context?: { + silent?: boolean + } onSuccess?: (data: PublishAgentResponse, variables: PublishAgentVariables) => void }) => ({ mutationFn: async (variables: PublishAgentVariables) => { @@ -98,11 +110,13 @@ const publishAgentMutationOptions = vi.hoisted(() => function createDeferredPromise() { let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve + reject = promiseReject }) - return { promise, resolve } + return { promise, reject, resolve } } function setDocumentVisibilityState(visibilityState: DocumentVisibilityState) { @@ -168,10 +182,12 @@ function renderUseAgentConfigureSync({ agentName = 'Agent', baseConfig, currentModel, + enabled = true, }: { agentName?: Parameters[0]['agentName'] baseConfig?: Parameters[0]['baseConfig'] currentModel?: Parameters[0]['currentModel'] + enabled?: boolean } = {}) { const queryClient = new QueryClient({ defaultOptions: { @@ -194,7 +210,7 @@ function renderUseAgentConfigureSync({ agentName, baseConfig, currentModel, - enabled: true, + enabled, }), { wrapper }, ), @@ -207,6 +223,7 @@ describe('useAgentConfigureSync', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + composerPutRequestContexts.length = 0 }) afterEach(() => { @@ -215,16 +232,12 @@ describe('useAgentConfigureSync', () => { }) it('should automatically save configure page changes to draft', async () => { - vi.setSystemTime(1710000100000) - const { queryClient, result, store } = renderUseAgentConfigureSync() - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const { queryClient, store } = renderUseAgentConfigureSync() queryClient.setQueryData(['agent-detail', 'agent-1'], { active_config_is_published: true, name: 'Agent', }) - expect(result.current.draftSavedAt).toBeUndefined() - act(() => { store.set(agentComposerDraftAtom, { ...defaultAgentSoulConfigFormState, @@ -258,25 +271,14 @@ describe('useAgentConfigureSync', () => { }), }), ) - expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({ - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Draft only prompt', - }), - }), - }) expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({ active_config_is_published: true, name: 'Agent', }) - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['agent-detail', 'agent-1'], - }) - expect(result.current.draftSavedAt).toBe(1710000105000) }) it('should cancel pending autosave when the draft returns to the saved baseline', async () => { - const { queryClient, result, store } = renderUseAgentConfigureSync() + const { queryClient, store } = renderUseAgentConfigureSync() queryClient.setQueryData(['agent-detail', 'agent-1'], { active_config_is_published: true, name: 'Agent', @@ -301,7 +303,6 @@ describe('useAgentConfigureSync', () => { active_config_is_published: true, name: 'Agent', }) - expect(result.current.draftSavedAt).toBeUndefined() }) it('should save dirty draft once when the page is closing', async () => { @@ -326,6 +327,7 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + expect(composerPutRequestContexts).toEqual([{ keepalive: true, silent: true }]) expect(composerPutMutationFn).toHaveBeenCalledWith( expect.objectContaining({ params: { @@ -349,6 +351,101 @@ describe('useAgentConfigureSync', () => { }) }) + it('should dispatch the latest keepalive save while an earlier save is pending', async () => { + const saveDeferred = createDeferredPromise<{ agent_soul: Record }>() + composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise) + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Explicit save prompt', + }) + }) + + let saveDraftPromise!: Promise + act(() => { + saveDraftPromise = result.current.saveDraft() + }) + await act(async () => { + await Promise.resolve() + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Latest closing prompt', + }) + }) + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutRequestContexts).toEqual([ + { silent: true }, + { keepalive: true, silent: true }, + ]) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Latest closing prompt', + }), + }), + }), + }), + ) + + await act(async () => { + saveDeferred.resolve({ agent_soul: {} }) + await saveDraftPromise + await Promise.resolve() + }) + expect(store.get(agentComposerSavedDraftAtom)?.prompt).toBe('Latest closing prompt') + }) + + it('should repeat an in-flight explicit save with keepalive before unload', async () => { + const saveDeferred = createDeferredPromise<{ agent_soul: Record }>() + composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise) + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Pending explicit save', + }) + }) + + let saveDraftPromise!: Promise + act(() => { + saveDraftPromise = result.current.saveDraft() + }) + await act(async () => { + await Promise.resolve() + }) + + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutRequestContexts).toEqual([ + { silent: true }, + { keepalive: true, silent: true }, + ]) + + await act(async () => { + saveDeferred.resolve({ agent_soul: {} }) + await saveDraftPromise + await Promise.resolve() + }) + }) + it('should save the latest dirty draft when Configure unmounts before autosave runs', async () => { const { store, unmount } = renderUseAgentConfigureSync() @@ -551,7 +648,7 @@ describe('useAgentConfigureSync', () => { }) it('should autosave when knowledge retrieval validation fails', async () => { - const { result, store } = renderUseAgentConfigureSync() + const { store } = renderUseAgentConfigureSync() act(() => { store.set(agentComposerDraftAtom, { @@ -571,12 +668,11 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) - expect(result.current.draftSavedAt).toBeDefined() }) it('should keep autosave failures silent and leave the local draft dirty', async () => { composerPutMutationFn.mockRejectedValueOnce(new Error('save failed')) - const { result, store } = renderUseAgentConfigureSync() + const { store } = renderUseAgentConfigureSync() act(() => { store.set(agentComposerDraftAtom, { @@ -590,13 +686,12 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) - expect(result.current.draftSavedAt).toBeUndefined() expect(store.get(agentComposerDraftAtom).prompt).toBe('Unsaved autosave prompt') expect(toastMock.error).not.toHaveBeenCalled() + expect(composerPutRequestContexts).toEqual([{ silent: true }]) }) it('should save the latest draft immediately when requested', async () => { - vi.setSystemTime(1710000200000) const { result, store } = renderUseAgentConfigureSync() act(() => { @@ -627,7 +722,6 @@ describe('useAgentConfigureSync', () => { }), }), ) - expect(result.current.draftSavedAt).toBe(1710000200000) }) it('should reject explicit save requests when the draft cannot be saved', async () => { @@ -642,7 +736,6 @@ describe('useAgentConfigureSync', () => { }) await expect(result.current.saveDraft()).rejects.toThrow('Failed to save agent composer draft.') - expect(result.current.draftSavedAt).toBeUndefined() expect(store.get(agentComposerDraftAtom).prompt).toBe('Run prompt') expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') }) @@ -663,7 +756,6 @@ describe('useAgentConfigureSync', () => { active_config_is_published: true, name: 'Agent', }) - expect(result.current.draftSavedAt).toBeUndefined() }) it('should save the effective model before run when the form draft is unchanged', async () => { @@ -723,14 +815,9 @@ describe('useAgentConfigureSync', () => { }) it('should publish only when publishDraft is called explicitly', async () => { - const { queryClient, result, store } = renderUseAgentConfigureSync({ + const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, }) - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') - queryClient.setQueryData(['agent-detail', 'agent-1'], { - active_config_is_published: false, - name: 'Agent', - }) act(() => { store.set(agentComposerDraftAtom, { ...defaultAgentSoulConfigFormState, @@ -764,13 +851,6 @@ describe('useAgentConfigureSync', () => { }, body: {}, }) - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['agent-composer', 'agent-1'], - }) - expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({ - active_config_is_published: true, - name: 'Agent', - }) expect(trackEventMock).toHaveBeenCalledWith('app_published_time', { action_mode: 'app', app_id: 'agent-1', @@ -800,7 +880,7 @@ describe('useAgentConfigureSync', () => { expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel') }) - it('should keep default model fallback from creating unpublished changes after publish', async () => { + it('should keep default model fallback from leaving the local draft dirty after publish', async () => { const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, }) @@ -816,13 +896,13 @@ describe('useAgentConfigureSync', () => { }) expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) - const publishedDraft = store.get(agentComposerPublishedDraftAtom) + const savedDraft = store.get(agentComposerSavedDraftAtom) expect(store.get(agentComposerDraftAtom).model).toBeUndefined() - expect(publishedDraft?.model).toBeUndefined() - expect(publishedDraft).toEqual(store.get(agentComposerDraftAtom)) + expect(savedDraft?.model).toBeUndefined() + expect(savedDraft).toEqual(store.get(agentComposerDraftAtom)) }) - it('should keep base config fallback fields from creating unpublished changes after publish', async () => { + it('should keep base config fallback fields from leaving the local draft dirty after publish', async () => { const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, baseConfig: { @@ -845,10 +925,10 @@ describe('useAgentConfigureSync', () => { }) expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) - const publishedDraft = store.get(agentComposerPublishedDraftAtom) + const savedDraft = store.get(agentComposerSavedDraftAtom) expect(store.get(agentComposerDraftAtom).appFeatures).toBeUndefined() - expect(publishedDraft?.appFeatures).toBeUndefined() - expect(publishedDraft).toEqual(store.get(agentComposerDraftAtom)) + expect(savedDraft?.appFeatures).toBeUndefined() + expect(savedDraft).toEqual(store.get(agentComposerDraftAtom)) }) it('should publish the current draft snapshot instead of a stale caller payload', async () => { @@ -884,13 +964,9 @@ describe('useAgentConfigureSync', () => { it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => { composerPutMutationFn.mockRejectedValueOnce(new Error('save failed')) - const { queryClient, result, store } = renderUseAgentConfigureSync({ + const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, }) - queryClient.setQueryData(['agent-detail', 'agent-1'], { - active_config_is_published: false, - name: 'Agent', - }) act(() => { store.set(agentComposerDraftAtom, { @@ -904,13 +980,23 @@ describe('useAgentConfigureSync', () => { ) expect(publishAgentMutationFn).not.toHaveBeenCalled() - expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({ - active_config_is_published: false, - name: 'Agent', - }) expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') }) + it('should skip publish while the Composer Query is unavailable', async () => { + const { result } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + enabled: false, + }) + + await act(async () => { + await result.current.publishDraft() + }) + + expect(composerPutMutationFn).not.toHaveBeenCalled() + expect(publishAgentMutationFn).not.toHaveBeenCalled() + }) + it('should toast and skip publish when knowledge retrieval validation fails', async () => { const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, @@ -1000,4 +1086,180 @@ describe('useAgentConfigureSync', () => { expect(result.current.isPublishing).toBe(false) }) + + it('should pause autosave during publish and resume it for edits made in flight', async () => { + const publishDeferred = createDeferredPromise() + publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise) + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Draft captured for publish', + }) + }) + + let publishPromise!: Promise + act(() => { + publishPromise = result.current.publishDraft() + }) + await act(async () => { + await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Edited while publish is pending', + }) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + + await act(async () => { + publishDeferred.resolve({ + active_config_snapshot: {}, + active_config_snapshot_id: 'snapshot-1', + result: 'success', + }) + await publishPromise + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Edited while publish is pending', + }), + }), + }), + }), + ) + }) + + it('should dispatch a keepalive save for edits made while publish is pending', async () => { + const publishDeferred = createDeferredPromise() + publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise) + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Draft captured for publish', + }) + }) + + let publishPromise!: Promise + act(() => { + publishPromise = result.current.publishDraft() + }) + await act(async () => { + await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Latest edit before closing', + }) + }) + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutRequestContexts).toEqual([ + { silent: true }, + { keepalive: true, silent: true }, + ]) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Latest edit before closing', + }), + }), + }), + }), + ) + + await act(async () => { + publishDeferred.resolve({ + active_config_snapshot: {}, + active_config_snapshot_id: 'snapshot-1', + result: 'success', + }) + await publishPromise + await Promise.resolve() + }) + }) + + it('should resume autosave for edits made while publish fails', async () => { + const publishDeferred = createDeferredPromise() + publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise) + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Draft captured for failed publish', + }) + }) + + let publishPromise!: Promise + act(() => { + publishPromise = result.current.publishDraft() + }) + await act(async () => { + await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Edited while failed publish is pending', + }) + }) + await act(async () => { + publishDeferred.reject(new Error('publish failed')) + await expect(publishPromise).rejects.toThrow('publish failed') + await Promise.resolve() + await Promise.resolve() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(result.current.isPublishing).toBe(false) + expect(toastMock.error).toHaveBeenCalledTimes(1) + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Edited while failed publish is pending', + }), + }), + }), + }), + ) + }) }) 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 a845da96da4..15964340ff5 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 @@ -99,7 +99,8 @@ export function AgentConfigureComposerScope({ } initializedComposerAgentIdRef.current = agentId - const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}` + const composerHydrationState = composerQuery.data === undefined ? 'unavailable' : 'loaded' + const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerHydrationState}:${composerRebaseRevision}` return ( { rebaseComposerDraft({ draft: agentSoulConfigToFormState(agentSoulConfig), - originalConfig: agentSoulConfig, }) }, [rebaseComposerDraft], ) const { currentModel, setConfigureModel, textGenerationModelList } = useAgentConfigureModelOptions() - const { draftSavedAt, isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({ + const { isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({ agentId, agentName: agentQuery.data?.name, baseConfig: agentSoulConfig, @@ -449,20 +448,21 @@ function AgentConfigurePageComposerContent({ leftPanel={ ({ const workflowReferences = vi.hoisted(() => ({ fetchCount: 0, data: [] as AgentReferencingWorkflowResponse[], + shouldFail: false, +})) +const composerQuery = vi.hoisted(() => ({ + data: undefined as unknown, + shouldFail: false, })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -92,22 +96,39 @@ vi.mock('@/service/client', () => ({ 'agent-composer', input, ], + queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({ + queryKey: ['agent-composer', input], + queryFn: async () => { + if (composerQuery.shouldFail) throw new Error('Composer query failed') + + return composerQuery.data + }, + }), }, }, referencingWorkflows: { get: { queryOptions: ({ + context, enabled = true, input, }: { + context?: { silent?: boolean } enabled?: boolean input: { params: { agent_id: string } } }) => ({ queryKey: ['agent-referencing-workflows', input], enabled, - queryFn: async () => ({ - data: (workflowReferences.fetchCount++, workflowReferences.data), - }), + context, + queryFn: async () => { + workflowReferences.fetchCount++ + if (workflowReferences.shouldFail) + throw new Error('Workflow references query failed') + + return { + data: workflowReferences.data, + } + }, }), }, }, @@ -135,7 +156,7 @@ const activeConfigSnapshot: AgentConfigSnapshotSummaryResponse = { created_at: 1710000000, } -const originalDraftWithFile = { +const savedDraftWithFile = { ...defaultAgentSoulConfigFormState, tools: [ { @@ -184,6 +205,8 @@ function renderPublishBar({ activeConfigIsPublished, activeConfigSnapshot, draftSavedAt, + composerQueryAvailable = true, + composerQueryFails = false, isPublishing, onPublish = vi.fn(), onExitVersions = vi.fn(), @@ -192,11 +215,12 @@ function renderPublishBar({ selectedVersionSnapshot, setupStore, usedByAppReferences = [], - workflowReferencesEnabled, }: { activeConfigIsPublished?: boolean activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null draftSavedAt?: number + composerQueryAvailable?: boolean + composerQueryFails?: boolean isPublishing?: boolean onPublish?: PublishMock onExitVersions?: Mock<() => void> @@ -205,9 +229,9 @@ function renderPublishBar({ selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null setupStore?: (store: ReturnType) => void usedByAppReferences?: AgentReferencingWorkflowResponse[] - workflowReferencesEnabled?: boolean } = {}) { workflowReferences.data = usedByAppReferences + composerQuery.shouldFail = composerQueryFails const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -217,31 +241,41 @@ function renderPublishBar({ const store = createStore() store.set(agentComposerPromptAtom, prompt) setupStore?.(store) + const composerQueryKey = ['agent-composer', { params: { agent_id: 'agent-1' } }] + const composerState = { + active_config_is_published: activeConfigIsPublished ?? false, + active_config_snapshot: activeConfigSnapshot, + agent: { + id: 'agent-1', + name: 'Iris', + }, + agent_soul: { + schema_version: 1, + }, + draft: draftSavedAt + ? { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: draftSavedAt / 1000, + } + : null, + save_options: ['save_to_current_version'], + variant: 'agent_app', + } + composerQuery.data = composerState + if (composerQueryAvailable) { + queryClient.setQueryData(composerQueryKey, composerState) + } - const renderPublishBarTree = (nextProps?: { - activeConfigIsPublished?: boolean - activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null - isPublishing?: boolean - }) => ( + const renderPublishBarTree = (nextProps?: { isPublishing?: boolean }) => ( { }) workflowReferences.data = [] workflowReferences.fetchCount = 0 + workflowReferences.shouldFail = false + composerQuery.data = undefined + composerQuery.shouldFail = false vi.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -412,25 +449,35 @@ describe('AgentConfigurePublishBar', () => { expect(onPublish).not.toHaveBeenCalled() }) - it('should keep published state when the published detail updates before the active snapshot is refreshed', () => { - const { rerender, rerenderPublishBar } = renderPublishBar({ - activeConfigIsPublished: true, - activeConfigSnapshot: null, + it('should fail closed while the Composer Query is unavailable', async () => { + renderPublishBar({ + composerQueryAvailable: false, + composerQueryFails: true, }) - rerender( - rerenderPublishBar({ - activeConfigIsPublished: undefined, - activeConfigSnapshot: undefined, - }), + await waitFor(() => { + expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeDisabled() + }) + expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeDisabled() + expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( + expect.objectContaining({ enabled: false, ignoreInputs: false }), ) + }) - expect( - screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate'), - ).toBeInTheDocument() - expect( - screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' }), - ).toBeDisabled() + it('should fail closed when refreshing cached Composer state fails', async () => { + renderPublishBar({ + activeConfigIsPublished: false, + activeConfigSnapshot, + composerQueryFails: true, + }) + + await waitFor(() => { + expect( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).toBeDisabled() + }) expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: false, ignoreInputs: false }), ) @@ -512,16 +559,15 @@ describe('AgentConfigurePublishBar', () => { }) }) - it('should publish without loading workflow references when references are disabled', async () => { + it('should fail closed and show feedback when workflow references cannot be loaded', async () => { + workflowReferences.shouldFail = true const { onPublish } = renderPublishBar({ activeConfigSnapshot, prompt: 'Updated system prompt', - usedByAppReferences: publishedReferences, - workflowReferencesEnabled: false, }) await waitFor(() => { - expect(workflowReferences.fetchCount).toBe(0) + expect(workflowReferences.fetchCount).toBe(1) }) fireEvent.click( screen.getByRole('button', { @@ -530,24 +576,18 @@ describe('AgentConfigurePublishBar', () => { ) await waitFor(() => { - expect(onPublish).toHaveBeenCalledTimes(1) + expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') }) - expect(workflowReferences.fetchCount).toBe(0) - expect( - screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - }), - ).not.toBeInTheDocument() + expect(onPublish).not.toHaveBeenCalled() }) it('should mark non-prompt draft changes as unpublished', () => { renderPublishBar({ activeConfigSnapshot, setupStore: (store) => { - store.set(agentComposerPublishedDraftAtom, originalDraftWithFile) - store.set(agentComposerOriginalDraftAtom, originalDraftWithFile) + store.set(agentComposerSavedDraftAtom, savedDraftWithFile) store.set(agentComposerDraftAtom, { - ...originalDraftWithFile, + ...savedDraftWithFile, tools: [], }) }, @@ -559,20 +599,16 @@ describe('AgentConfigurePublishBar', () => { }) it('should keep unpublished state after draft autosave updates the saved draft baseline', () => { - const publishedDraft = { - ...defaultAgentSoulConfigFormState, - prompt: 'Published prompt', - } const savedDraft = { ...defaultAgentSoulConfigFormState, prompt: 'Autosaved draft prompt', } renderPublishBar({ + activeConfigIsPublished: false, activeConfigSnapshot, setupStore: (store) => { - store.set(agentComposerPublishedDraftAtom, publishedDraft) - store.set(agentComposerOriginalDraftAtom, savedDraft) + store.set(agentComposerSavedDraftAtom, savedDraft) store.set(agentComposerDraftAtom, savedDraft) }, }) @@ -588,10 +624,6 @@ describe('AgentConfigurePublishBar', () => { }) it('should trust backend published state after autosave confirms the draft matches the active snapshot', () => { - const stalePublishedDraftBaseline = { - ...defaultAgentSoulConfigFormState, - prompt: 'Old unpublished normal draft', - } const savedDraftMatchingActiveSnapshot = { ...defaultAgentSoulConfigFormState, prompt: 'Published prompt', @@ -601,8 +633,7 @@ describe('AgentConfigurePublishBar', () => { activeConfigIsPublished: true, activeConfigSnapshot, setupStore: (store) => { - store.set(agentComposerPublishedDraftAtom, stalePublishedDraftBaseline) - store.set(agentComposerOriginalDraftAtom, savedDraftMatchingActiveSnapshot) + store.set(agentComposerSavedDraftAtom, savedDraftMatchingActiveSnapshot) store.set(agentComposerDraftAtom, savedDraftMatchingActiveSnapshot) }, }) @@ -757,6 +788,42 @@ describe('AgentConfigurePublishBar', () => { }) }) + it('should keep impact confirmation open without leaking a rejected publish command', async () => { + const onPublish = vi.fn(() => Promise.reject(new Error('publish failed'))) + renderPublishBar({ + activeConfigSnapshot, + onPublish, + prompt: 'Updated system prompt', + usedByAppReferences: publishedReferences, + }) + + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) + expect( + await screen.findByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) + + await waitFor(() => { + expect(onPublish).toHaveBeenCalledTimes(1) + }) + expect( + screen.getByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() + }) + it('should collapse affected workflow details from the expanded footer cancel action', async () => { const { onPublish } = renderPublishBar({ activeConfigSnapshot, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx index 4c036deed2b..1c78561972b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { AgentConfigApiContext } from '../../config-context' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { toast } from '@langgenius/dify-ui/toast' @@ -170,12 +169,10 @@ function createInitialDraft( function renderAgentFiles({ initialDraft = createInitialDraft(), - initialOriginalConfig, apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext, readOnly = false, }: { initialDraft?: AgentSoulConfigFormState - initialOriginalConfig?: AgentSoulConfig apiContext?: AgentConfigApiContext readOnly?: boolean } = {}) { @@ -193,10 +190,7 @@ function renderAgentFiles({ return render( - + diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index e05cae17669..cd16df3e505 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -33,18 +33,14 @@ type AgentOrchestratePanelProps = { agentId: string appId?: string nodeId?: string - activeConfigIsPublished?: boolean - activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot'] agentName?: string | null currentModel?: AgentComposerModel textGenerationModelList: Model[] - draftSavedAt?: number isPublishing?: boolean className?: string readOnly?: boolean selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null - workflowReferencesEnabled?: boolean isBuildDraftActive?: boolean buildDraftChangedKeys?: readonly AgentBuildDraftChangedKey[] showHeader?: boolean @@ -62,18 +58,14 @@ export function AgentOrchestratePanel({ agentId, appId, nodeId, - activeConfigIsPublished, - activeConfigSnapshot, agentSoulConfig: _agentSoulConfig, agentName, currentModel, textGenerationModelList, - draftSavedAt, isPublishing, className, readOnly = false, selectedVersionSnapshot, - workflowReferencesEnabled, isBuildDraftActive = false, buildDraftChangedKeys = [], showHeader = true, @@ -94,13 +86,9 @@ export function AgentOrchestratePanel({ (showPublishBar ? ( void | Promise onExitVersions?: () => void onOpenVersions?: () => void @@ -52,13 +45,11 @@ function getPublishState({ activeConfigIsPublished, activeConfigSnapshot, hasLocalChanges, - hasUnpublishedChanges, isPublishing, }: { activeConfigIsPublished?: boolean activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null hasLocalChanges: boolean - hasUnpublishedChanges: boolean isPublishing: boolean }): AgentConfigurePublishState { if (isPublishing) return 'publishing' @@ -67,13 +58,9 @@ function getPublishState({ if (activeConfigIsPublished) return 'published' - if (hasUnpublishedChanges) return 'unpublished' - if (!activeConfigSnapshot) return 'draft' - if (!activeConfigIsPublished) return 'unpublished' - - return 'published' + return 'unpublished' } function PublishShortcut() { @@ -90,13 +77,9 @@ function PublishShortcut() { export function AgentConfigurePublishBar({ agentId, - activeConfigIsPublished, - activeConfigSnapshot, agentName, - draftSavedAt, isPublishing = false, selectedVersionSnapshot, - workflowReferencesEnabled = true, onPublish, onExitVersions, onOpenVersions, @@ -107,29 +90,37 @@ export function AgentConfigurePublishBar({ const { formatTimeFromNow } = useFormatTimeFromNow() const queryClient = useQueryClient() const [publishBarMode, setPublishBarMode] = useState({ status: 'compact' }) - const lastKnownPublishedRef = useRef(false) - if (activeConfigIsPublished === true) lastKnownPublishedRef.current = true - if (activeConfigIsPublished === false) lastKnownPublishedRef.current = false - const stableActiveConfigIsPublished = - activeConfigIsPublished ?? (lastKnownPublishedRef.current ? true : undefined) - const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom) + const composerQuery = useQuery( + consoleQuery.agent.byAgentId.composer.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, + }, + }), + ) + const activeConfigIsPublished = composerQuery.data?.active_config_is_published + const activeConfigSnapshot = composerQuery.data?.active_config_snapshot + const draftSavedAt = composerQuery.data?.draft?.updated_at + ? composerQuery.data.draft.updated_at * 1000 + : undefined const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom) const publishableState = getPublishState({ - activeConfigIsPublished: stableActiveConfigIsPublished, + activeConfigIsPublished, activeConfigSnapshot, hasLocalChanges, - hasUnpublishedChanges, isPublishing: false, }) const publishState = getPublishState({ - activeConfigIsPublished: stableActiveConfigIsPublished, + activeConfigIsPublished, activeConfigSnapshot, hasLocalChanges, - hasUnpublishedChanges, isPublishing, }) const publishIsAvailable = - !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished') + composerQuery.isSuccess && + !isPublishing && + (publishableState === 'draft' || publishableState === 'unpublished') const workflowReferencesQueryOptions = consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ input: { @@ -137,7 +128,10 @@ export function AgentConfigurePublishBar({ agent_id: agentId, }, }, - enabled: workflowReferencesEnabled && publishIsAvailable && !selectedVersionSnapshot, + context: { + silent: true, + }, + enabled: publishIsAvailable && !selectedVersionSnapshot, }) const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions) const restoreVersionMutation = useMutation( @@ -206,16 +200,19 @@ export function AgentConfigurePublishBar({ return } - const cachedReferences = queryClient.getQueryData( - workflowReferencesQueryOptions.queryKey, - ) - const references = workflowReferencesEnabled - ? (( - cachedReferences ?? - workflowReferencesQuery.data ?? - (await queryClient.ensureQueryData(workflowReferencesQueryOptions)) - )?.data ?? []) - : [] + let referencesResponse: AgentReferencingWorkflowsResponse | undefined + try { + referencesResponse = + queryClient.getQueryData( + workflowReferencesQueryOptions.queryKey, + ) ?? + workflowReferencesQuery.data ?? + (await queryClient.ensureQueryData(workflowReferencesQueryOptions)) + } catch { + toast.error(tCommon(($) => $['api.actionFailed'])) + return + } + const references = referencesResponse?.data ?? [] if (references.length > 0) { setPublishBarMode({ status: 'confirmingImpact', references }) @@ -225,11 +222,15 @@ export function AgentConfigurePublishBar({ await handlePublish() } + const requestPublish = () => { + void handlePublishRequest().catch(() => undefined) + } + useHotkey( PUBLISH_AGENT_HOTKEY, (event) => { event.preventDefault() - void handlePublishRequest() + requestPublish() }, { enabled: canPublish && !selectedVersionSnapshot, @@ -331,7 +332,7 @@ export function AgentConfigurePublishBar({ canPublish={canPublish} onCancelImpact={() => setPublishBarMode({ status: 'compact' })} onOpenVersions={() => onOpenVersions?.()} - onPublishRequest={handlePublishRequest} + onPublishRequest={requestPublish} /> ) @@ -360,7 +361,7 @@ function PublishBarActions({ canPublish: boolean onCancelImpact: () => void onOpenVersions: () => void - onPublishRequest: () => void | Promise + onPublishRequest: () => void }) { const { t } = useTranslation('agentV2') @@ -402,9 +403,7 @@ function PublishBarActions({ disabled={!canPublish} loading={isPublishing} className="h-8 gap-1 rounded-lg px-3" - onClick={() => { - void onPublishRequest() - }} + onClick={onPublishRequest} > {actionIcon && } {actionLabel} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx index 472f3a91044..6419ea4c7c1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx @@ -12,8 +12,7 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' import { agentComposerDraftAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' import { AgentOrchestrateReadOnlyContext } from '../../read-only-context' @@ -325,8 +324,7 @@ function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agen }) const store = createStore() store.set(agentComposerDraftAtom, initialDraft) - store.set(agentComposerOriginalDraftAtom, initialDraft) - store.set(agentComposerPublishedDraftAtom, initialDraft) + store.set(agentComposerSavedDraftAtom, initialDraft) const view = render( 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 4496ff774c3..6e77988a1f0 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 @@ -4,11 +4,11 @@ import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.ge import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { toast } from '@langgenius/dify-ui/toast' -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query' import { debounce } from 'es-toolkit/compat' import isEqual from 'fast-deep-equal' import { useSetAtom, useStore } from 'jotai' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' @@ -19,9 +19,7 @@ import { } from '@/features/agent-v2/agent-composer/knowledge-validation' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' import { consoleQuery } from '@/service/client' @@ -45,15 +43,13 @@ export function useAgentConfigureSync({ const getKnowledgeValidationMessage = useKnowledgeValidationMessage() const queryClient = useQueryClient() const store = useStore() - const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom) - const setOriginalDraft = useSetAtom(agentComposerOriginalDraftAtom) - const setPublishedDraft = useSetAtom(agentComposerPublishedDraftAtom) - const [draftSavedAt, setDraftSavedAt] = useState(undefined) - const [isPublishInFlight, setIsPublishInFlight] = useState(false) + const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom) const baseConfigRef = useRef(baseConfig) const currentModelRef = useRef(currentModel) const enabledRef = useRef(enabled) const lastAutosavedDraftKeyRef = useRef(undefined) + const latestAppliedSaveSequenceRef = useRef(0) + const nextSaveSequenceRef = useRef(0) const pageCloseSavingDraftKeyRef = useRef(undefined) const explicitlySavingDraftKeysRef = useRef(new Set()) const publishInFlightRef = useRef(false) @@ -73,28 +69,63 @@ export function useAgentConfigureSync({ ) const { mutateAsync: saveComposerDraft } = useMutation( - consoleQuery.agent.byAgentId.composer.put.mutationOptions(), + consoleQuery.agent.byAgentId.composer.put.mutationOptions({ + context: { + silent: true, + }, + }), ) - const { isPending: isPublishingAgent, mutateAsync: publishAgent } = useMutation( - consoleQuery.agent.byAgentId.publish.post.mutationOptions(), + const { mutateAsync: saveComposerDraftOnPageClose } = useMutation( + consoleQuery.agent.byAgentId.composer.put.mutationOptions({ + context: { + keepalive: true, + silent: true, + }, + }), + ) + const { mutateAsync: publishAgent } = useMutation( + consoleQuery.agent.byAgentId.publish.post.mutationOptions({ + context: { + silent: true, + }, + }), + ) + + const applySavedDraft = useCallback( + ({ + draftBaseline, + draftKey, + saveSequence, + }: { + draftBaseline: AgentSoulConfigFormState + draftKey: string + saveSequence: number + }) => { + if (saveSequence < latestAppliedSaveSequenceRef.current) return + + latestAppliedSaveSequenceRef.current = saveSequence + setSavedDraft(draftBaseline) + lastAutosavedDraftKeyRef.current = draftKey + }, + [setSavedDraft], ) const saveComposer = useSerialAsyncCallback( async ({ configSnapshot, draftBaseline, + publish = false, silent = true, }: { configSnapshot: AgentSoulConfig draftBaseline: AgentSoulConfigFormState + publish?: boolean silent?: boolean }) => { const savedDraftKey = JSON.stringify(configSnapshot) - const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ - input: { params: { agent_id: agentId } }, - }) + const saveSequence = ++nextSaveSequenceRef.current try { - const composerState = await saveComposerDraft({ + await saveComposerDraft({ params: { agent_id: agentId, }, @@ -104,32 +135,93 @@ export function useAgentConfigureSync({ agent_soul: configSnapshot, }, }) - queryClient.setQueryData( - consoleQuery.agent.byAgentId.composer.get.queryKey({ - input: { params: { agent_id: agentId } }, - }), - composerState, - ) - await queryClient.invalidateQueries({ - queryKey: agentDetailQueryKey, - }) } catch { // Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary. if (!silent) { - toast.error(tCommon(($) => $['api.actionFailed'])) throw new Error('Failed to save agent composer draft.') } return false } - setOriginalDraft(draftBaseline) - setDraftSavedAt(Date.now()) - lastAutosavedDraftKeyRef.current = savedDraftKey + applySavedDraft({ + draftBaseline, + draftKey: savedDraftKey, + saveSequence, + }) + + if (publish) { + await publishAgent({ + params: { + agent_id: agentId, + }, + body: {}, + }) + await queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.versions.get.key(), + }) + } + return true }, ) + const saveComposerOnPageClose = useCallback( + async ({ + configSnapshot, + draftBaseline, + draftKey, + }: { + configSnapshot: AgentSoulConfig + draftBaseline: AgentSoulConfigFormState + draftKey: string + }) => { + const saveSequence = ++nextSaveSequenceRef.current + try { + await saveComposerDraftOnPageClose({ + params: { + agent_id: agentId, + }, + body: { + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: configSnapshot, + }, + }) + } catch { + return false + } + + applySavedDraft({ + draftBaseline, + draftKey, + saveSequence, + }) + return true + }, + [agentId, applySavedDraft, saveComposerDraftOnPageClose], + ) + + const { isPending: isPublishing, mutateAsync: runPublishTransaction } = useMutation( + mutationOptions({ + mutationKey: ['agent-configure', agentId, 'publish'], + mutationFn: async ({ + configSnapshot, + draftBaseline, + }: { + configSnapshot: AgentSoulConfig + draftBaseline: AgentSoulConfigFormState + }) => { + await saveComposer({ + configSnapshot, + draftBaseline, + publish: true, + silent: false, + }) + }, + }), + ) + const latestDraftSaveRef = useRef<() => void>(() => undefined) latestDraftSaveRef.current = () => { const draft = store.get(agentComposerDraftAtom) @@ -165,41 +257,46 @@ export function useAgentConfigureSync({ draftBaseline: draft, silent: false, }) + } catch (error) { + toast.error(tCommon(($) => $['api.actionFailed'])) + throw error } finally { explicitlySavingDraftKeysRef.current.delete(draftKey) } - }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store]) + }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store, tCommon]) - const saveDirtyDraftOnPageClose = useCallback(() => { - if (!enabledRef.current || publishInFlightRef.current) { - return - } + const saveDirtyDraftOnPageClose = useCallback( + (allowInFlightDuplicate = false) => { + if (!enabledRef.current) return - const draft = store.get(agentComposerDraftAtom) - if (!store.get(isAgentComposerDirtyAtom)) { - return - } + const draft = store.get(agentComposerDraftAtom) + if (!store.get(isAgentComposerDirtyAtom)) { + return + } - const configSnapshot = getAgentSoulDraft() - const draftKey = JSON.stringify(configSnapshot) - if ( - lastAutosavedDraftKeyRef.current === draftKey || - pageCloseSavingDraftKeyRef.current === draftKey || - explicitlySavingDraftKeysRef.current.has(draftKey) - ) { - return - } + const configSnapshot = getAgentSoulDraft() + const draftKey = JSON.stringify(configSnapshot) + if ( + lastAutosavedDraftKeyRef.current === draftKey || + pageCloseSavingDraftKeyRef.current === draftKey || + (!allowInFlightDuplicate && explicitlySavingDraftKeysRef.current.has(draftKey)) + ) { + return + } - debouncedSaveDraft.cancel?.() - pageCloseSavingDraftKeyRef.current = draftKey - void saveComposer({ - configSnapshot, - draftBaseline: draft, - }).finally(() => { - if (pageCloseSavingDraftKeyRef.current === draftKey) - pageCloseSavingDraftKeyRef.current = undefined - }) - }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store]) + debouncedSaveDraft.cancel?.() + pageCloseSavingDraftKeyRef.current = draftKey + void saveComposerOnPageClose({ + configSnapshot, + draftBaseline: draft, + draftKey, + }).finally(() => { + if (pageCloseSavingDraftKeyRef.current === draftKey) + pageCloseSavingDraftKeyRef.current = undefined + }) + }, + [debouncedSaveDraft, getAgentSoulDraft, saveComposerOnPageClose, store], + ) useEffect(() => { return store.sub(agentComposerDraftAtom, () => { @@ -207,7 +304,7 @@ export function useAgentConfigureSync({ const agentSoulDraftKey = JSON.stringify(agentSoulDraft) const isDirty = store.get(isAgentComposerDirtyAtom) - if (!enabledRef.current || !isDirty) { + if (!enabledRef.current || publishInFlightRef.current || !isDirty) { if (!isDirty) debouncedSaveDraft.cancel?.() return } @@ -222,10 +319,10 @@ export function useAgentConfigureSync({ useEffect(() => { const saveDraftWhenPageHidden = () => { - if (document.visibilityState === 'hidden') saveDirtyDraftOnPageClose() + if (document.visibilityState === 'hidden') saveDirtyDraftOnPageClose(true) } const saveDraftBeforeUnload = () => { - saveDirtyDraftOnPageClose() + saveDirtyDraftOnPageClose(true) } document.addEventListener('visibilitychange', saveDraftWhenPageHidden) @@ -244,7 +341,7 @@ export function useAgentConfigureSync({ }, [saveDirtyDraftOnPageClose]) const publishDraft = useCallback(async () => { - if (publishInFlightRef.current) return + if (!enabledRef.current || publishInFlightRef.current) return const draft = store.get(agentComposerDraftAtom) const configSnapshot = formStateToAgentSoulConfig({ @@ -267,45 +364,12 @@ export function useAgentConfigureSync({ } publishInFlightRef.current = true - setIsPublishInFlight(true) try { debouncedSaveDraft.cancel?.() - const saved = await saveComposer({ + await runPublishTransaction({ configSnapshot, draftBaseline: draft, - silent: false, }) - if (!saved) return - - await publishAgent({ - params: { - agent_id: agentId, - }, - body: {}, - }) - queryClient.setQueryData( - consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), - (agentDetail) => { - if (!agentDetail) return agentDetail - - return { - ...agentDetail, - active_config_is_published: true, - } - }, - ) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({ - input: { params: { agent_id: agentId } }, - }), - }) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.versions.get.key(), - }) - setOriginalConfig(configSnapshot) - const publishedDraft = draft - setOriginalDraft(publishedDraft) - setPublishedDraft(publishedDraft) trackEvent('app_published_time', { action_mode: 'app', app_id: agentId, @@ -313,28 +377,25 @@ export function useAgentConfigureSync({ app_mode: 'agent-v2', }) toast.success(tCommon(($) => $['api.actionSuccess'])) + } catch (error) { + toast.error(tCommon(($) => $['api.actionFailed'])) + throw error } finally { publishInFlightRef.current = false - setIsPublishInFlight(false) + if (enabledRef.current && store.get(isAgentComposerDirtyAtom)) debouncedSaveDraft() } }, [ agentId, agentName, debouncedSaveDraft, getKnowledgeValidationMessage, - publishAgent, - queryClient, - saveComposer, - setOriginalConfig, - setOriginalDraft, - setPublishedDraft, + runPublishTransaction, store, tCommon, ]) return { - draftSavedAt, - isPublishing: isPublishInFlight || isPublishingAgent, + isPublishing, publishDraft, saveDraft, } diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index edd231cb3fa..21e0a9caf1e 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -129,6 +129,12 @@ const createComposerState = ( id: 'snapshot-1', version: 1, }, + draft: { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: 1710000100, + }, agent: { active_config_snapshot_id: 'snapshot-1', description: 'Agent description', @@ -156,6 +162,12 @@ const createAgentPublishResponse = ( version: 1, }, active_config_snapshot_id: 'snapshot-1', + draft: { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: 1710000200, + }, result: 'success', ...overrides, }) @@ -966,10 +978,43 @@ describe('consoleQuery agent mutation defaults', () => { page: 1, total: 1, }) + const composerQueryKey = consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + }) + queryClient.setQueryData( + composerQueryKey, + createComposerState({ + active_config_snapshot: { + id: 'snapshot-previous', + version: 1, + }, + agent_soul: { + config_note: 'Keep the cached composer state', + schema_version: 1, + }, + }), + ) + const publishResponse = createAgentPublishResponse({ + active_config_snapshot: { + id: 'snapshot-2', + version: 2, + }, + active_config_snapshot_id: 'snapshot-2', + draft: { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: 1710000300, + }, + }) const mutationOptions = consoleQuery.agent.byAgentId.publish.post.mutationOptions() await mutationOptions.onSuccess?.( - createAgentPublishResponse(), + publishResponse, { params: { agent_id: 'agent-1', @@ -990,16 +1035,40 @@ describe('consoleQuery agent mutation defaults', () => { queryKey: consoleQuery.agent.inviteOptions.get.key(), }) expect(queryClient.getQueryData(inviteOptionsQueryKey)).toBeUndefined() + expect(queryClient.getQueryData(composerQueryKey)).toEqual( + expect.objectContaining({ + active_config_is_published: true, + active_config_snapshot: publishResponse.active_config_snapshot, + agent_soul: { + config_note: 'Keep the cached composer state', + schema_version: 1, + }, + draft: publishResponse.draft, + }), + ) }) it('should invalidate roster list but keep invite options stable after saving an agent draft', async () => { const consoleQuery = await loadConsoleQuery() const queryClient = new QueryClient() const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const composerQueryKey = consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + }) + const savedComposerState = createComposerState({ + agent_soul: { + config_note: 'Saved composer state', + schema_version: 1, + }, + }) const mutationOptions = consoleQuery.agent.byAgentId.composer.put.mutationOptions() await mutationOptions.onSuccess?.( - createComposerState(), + savedComposerState, { params: { agent_id: 'agent-1', @@ -1022,6 +1091,7 @@ describe('consoleQuery agent mutation defaults', () => { expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: consoleQuery.agent.inviteOptions.get.key(), }) + expect(queryClient.getQueryData(composerQueryKey)).toEqual(savedComposerState) }) it('should invalidate invite option lists after deleting an agent', async () => { diff --git a/web/service/client.ts b/web/service/client.ts index 354b592baf3..0e6dfb3697d 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -1,4 +1,7 @@ -import type { AgentAppPagination } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentAppComposerResponse, + AgentAppPagination, +} from '@dify/contracts/api/console/agent/types.gen' import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen' import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen' import type { consoleRouterContract } from '@dify/contracts/console' @@ -662,7 +665,17 @@ export const consoleQuery: RouterUtils = createTanstackQue composer: { put: { mutationOptions: { - onSuccess: (_composerState, variables, _onMutateResult, context) => { + onSuccess: (composerState, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: variables.params.agent_id, + }, + }, + }), + composerState, + ) context.client.invalidateQueries({ queryKey: consoleQuery.agent.get.key(), }) @@ -681,7 +694,33 @@ export const consoleQuery: RouterUtils = createTanstackQue publish: { post: { mutationOptions: { - onSuccess: (_publishResult, _variables, _onMutateResult, context) => { + onSuccess: (publishResult, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: variables.params.agent_id, + }, + }, + }), + (composerState) => { + if (!composerState) return composerState + + return { + ...composerState, + active_config_is_published: true, + active_config_snapshot: publishResult.active_config_snapshot, + agent: { + ...composerState.agent, + active_config_snapshot_id: publishResult.active_config_snapshot_id, + }, + draft: + publishResult.draft === undefined + ? composerState.draft + : publishResult.draft, + } + }, + ) context.client.invalidateQueries({ queryKey: consoleQuery.agent.get.key(), })