refactor: make agent composer save and publish state consistent (#39637)

This commit is contained in:
Joel 2026-07-27 16:43:45 +08:00 committed by GitHub
parent 3c80857ea3
commit 481354ca70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 865 additions and 427 deletions

View File

@ -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 () => {

View File

@ -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<number | undefined>(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,
}

View File

@ -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,
}),

View File

@ -130,10 +130,8 @@ export function WorkflowRosterAgentOrchestratePanelContent(
<AgentComposerProvider
key={composerSessionKey}
initialDraft={agentSoulConfigToFormState(initialAgentSoulConfig)}
initialOriginalConfig={initialAgentSoulConfig}
>
<WorkflowRosterAgentOrchestratePanelContentInner
activeConfigSnapshot={activeConfigSnapshot}
agentId={agentId}
agentSoulConfig={initialAgentSoulConfig}
composerState={composerState}
@ -143,12 +141,10 @@ export function WorkflowRosterAgentOrchestratePanelContent(
}
function WorkflowRosterAgentOrchestratePanelContentInner({
activeConfigSnapshot,
agentId,
agentSoulConfig,
composerState,
}: {
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
agentId: string
agentSoulConfig: AgentSoulConfig
composerState?: {
@ -163,7 +159,6 @@ function WorkflowRosterAgentOrchestratePanelContentInner({
return (
<AgentOrchestratePanel
agentId={agentId}
activeConfigSnapshot={activeConfigSnapshot}
agentSoulConfig={agentSoulConfig}
agentName={composerState?.agent?.name}
currentModel={currentModel}
@ -268,11 +263,9 @@ function WorkflowInlineAgentConfigureWorkspaceComposerScope({
<AgentComposerProvider
key={composerSessionKey}
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
initialOriginalConfig={buildDraft.agentSoulConfig}
>
<WorkflowInlineAgentConfigureWorkspaceContent
{...props}
activeConfigSnapshot={activeConfigSnapshot}
agentId={agentId}
agentSoulConfig={agentSoulConfig}
buildDraft={buildDraft}
@ -283,7 +276,6 @@ function WorkflowInlineAgentConfigureWorkspaceComposerScope({
}
function WorkflowInlineAgentConfigureWorkspaceContent({
activeConfigSnapshot,
agentId,
agentSoulConfig,
buildDraft,
@ -296,7 +288,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
onSaveInlineToRoster,
open,
}: Omit<WorkflowInlineAgentConfigureWorkspaceProps, 'agentId'> & {
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
agentId: string
agentSoulConfig: AgentSoulConfig
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
@ -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}

View File

@ -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 (
<dl>
<dt>draft</dt>
<dd>{draft.prompt}</dd>
<dt>original draft</dt>
<dd>{originalDraft?.prompt}</dd>
<dt>published draft</dt>
<dd>{publishedDraft?.prompt}</dd>
<dt>original config</dt>
<dd>{originalConfig?.prompt?.system_prompt}</dd>
<dt>saved draft</dt>
<dd>{savedDraft?.prompt}</dd>
<dt>dirty</dt>
<dd>{String(isDirty)}</dd>
<dt>unpublished</dt>
<dd>{String(hasUnpublishedChanges)}</dd>
</dl>
)
}
@ -49,27 +36,15 @@ describe('AgentComposerProvider', () => {
...defaultAgentSoulConfigFormState,
prompt: 'Be precise.',
}
const initialOriginalConfig = {
prompt: {
system_prompt: 'Be precise.',
},
} satisfies AgentSoulConfig
render(
<AgentComposerProvider
initialDraft={initialDraft}
initialOriginalConfig={initialOriginalConfig}
>
<AgentComposerProvider initialDraft={initialDraft}>
<StoreSnapshot />
</AgentComposerProvider>,
)
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')
})
})

View File

@ -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', () => {

View File

@ -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 (
<ScopeProvider
atoms={[
[agentComposerOriginalConfigAtom, initialOriginalConfig],
[agentComposerDraftAtom, draft],
[agentComposerOriginalDraftAtom, draft],
[agentComposerPublishedDraftAtom, draft],
[agentComposerSavedDraftAtom, draft],
]}
name="AgentComposer"
>

View File

@ -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<AgentSoulConfig | undefined>(undefined)
export const agentComposerOriginalDraftAtom = atom<AgentSoulConfigFormState | undefined>(
defaultAgentSoulConfigFormState,
)
export const agentComposerPublishedDraftAtom = atom<AgentSoulConfigFormState | undefined>(
export const agentComposerSavedDraftAtom = atom<AgentSoulConfigFormState | undefined>(
defaultAgentSoulConfigFormState,
)
export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>(
@ -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)
})

View File

@ -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(
<QueryClientProvider client={queryClient}>
<AgentConfigureComposerScopeHarness />
</QueryClientProvider>,
)
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(
<QueryClientProvider client={queryClient}>
<AgentConfigureComposerScopeHarness />
</QueryClientProvider>,
)
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', () => {

View File

@ -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<string, unknown> },
variables: {
@ -51,6 +59,7 @@ const composerPutMutationOptions = vi.hoisted(() =>
agent_soul: Record<string, unknown>
}
}) => {
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((promiseResolve) => {
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<typeof useAgentConfigureSync>[0]['agentName']
baseConfig?: Parameters<typeof useAgentConfigureSync>[0]['baseConfig']
currentModel?: Parameters<typeof useAgentConfigureSync>[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<string, unknown> }>()
composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise)
const { result, store } = renderUseAgentConfigureSync()
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Explicit save prompt',
})
})
let saveDraftPromise!: Promise<void>
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<string, unknown> }>()
composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise)
const { result, store } = renderUseAgentConfigureSync()
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Pending explicit save',
})
})
let saveDraftPromise!: Promise<void>
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<PublishAgentResponse>()
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Draft captured for publish',
})
})
let publishPromise!: Promise<void>
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<PublishAgentResponse>()
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Draft captured for publish',
})
})
let publishPromise!: Promise<void>
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<PublishAgentResponse>()
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Draft captured for failed publish',
})
})
let publishPromise!: Promise<void>
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',
}),
}),
}),
}),
)
})
})

View File

@ -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 (
<AgentConfigurePageComposerSession
@ -218,7 +219,6 @@ function AgentConfigurePageComposerSession({
<AgentComposerProvider
key={composerSessionKey}
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
initialOriginalConfig={buildDraft.agentSoulConfig}
>
<AgentConfigurePageComposerContent
agentId={agentId}
@ -320,14 +320,13 @@ function AgentConfigurePageComposerContent({
(agentSoulConfig?: AgentSoulConfig) => {
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={
<AgentOrchestratePanel
agentId={agentId}
activeConfigIsPublished={composerQuery.data?.active_config_is_published}
activeConfigSnapshot={activeConfigSnapshot}
agentSoulConfig={buildDraft.agentSoulConfig}
agentName={agentQuery.data?.name}
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
draftSavedAt={draftSavedAt}
isPublishing={isPublishing}
readOnly={isViewingVersion || buildDraft.isActive || buildDraftActionsDisabled}
readOnly={
!composerQuery.isSuccess ||
isViewingVersion ||
buildDraft.isActive ||
buildDraftActionsDisabled
}
selectedVersionSnapshot={isViewingVersion ? activeConfigSnapshot : undefined}
isBuildDraftActive={buildDraft.isActive}
buildDraftChangedKeys={buildDraft.changedKeys}
showPublishBar={!buildDraft.isActive}
workflowReferencesEnabled={agentQuery.isSuccess}
bottomAction={
showBuildDraftBar ? (
<AgentBuildDraftBar

View File

@ -10,8 +10,7 @@ import { createStore, Provider as JotaiProvider } from 'jotai'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import {
agentComposerDraftAtom,
agentComposerOriginalDraftAtom,
agentComposerPublishedDraftAtom,
agentComposerSavedDraftAtom,
} from '@/features/agent-v2/agent-composer/store'
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
import { AgentConfigurePublishBar } from '../publish-bar'
@ -46,6 +45,11 @@ const toastMock = vi.hoisted(() => ({
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<PublishHandler>(),
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<typeof createStore>) => 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 }) => (
<QueryClientProvider client={queryClient}>
<JotaiProvider store={store}>
<AgentConfigurePublishBar
agentId="agent-1"
activeConfigIsPublished={
nextProps && 'activeConfigIsPublished' in nextProps
? nextProps.activeConfigIsPublished
: activeConfigIsPublished
}
activeConfigSnapshot={
nextProps && 'activeConfigSnapshot' in nextProps
? nextProps.activeConfigSnapshot
: activeConfigSnapshot
}
draftSavedAt={draftSavedAt}
agentName="Iris"
isPublishing={nextProps?.isPublishing ?? isPublishing}
selectedVersionSnapshot={selectedVersionSnapshot}
workflowReferencesEnabled={workflowReferencesEnabled}
onPublish={onPublish}
onExitVersions={onExitVersions}
onOpenVersions={vi.fn()}
@ -272,6 +306,9 @@ describe('AgentConfigurePublishBar', () => {
})
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<PublishHandler>(() => 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,

View File

@ -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(
<QueryClientTestProvider queryClient={queryClient}>
<AgentConfigApiContextProvider value={apiContext}>
<AgentComposerProvider
initialDraft={initialDraft}
initialOriginalConfig={initialOriginalConfig}
>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentFiles />
<ConfigSnapshotProbe />

View File

@ -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 ? (
<AgentConfigurePublishBar
agentId={agentId}
activeConfigIsPublished={activeConfigIsPublished}
activeConfigSnapshot={activeConfigSnapshot}
agentName={agentName}
draftSavedAt={draftSavedAt}
isPublishing={isPublishing}
selectedVersionSnapshot={selectedVersionSnapshot}
workflowReferencesEnabled={workflowReferencesEnabled}
onPublish={onPublish}
onExitVersions={onExitVersions}
onOpenVersions={onOpenVersions}

View File

@ -14,12 +14,9 @@ import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
hasAgentComposerUnpublishedChangesAtom,
isAgentComposerDirtyAtom,
} from '@/features/agent-v2/agent-composer/store'
import { isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client'
@ -35,13 +32,9 @@ type PublishBarMode =
type AgentConfigurePublishBarProps = {
agentId: string
activeConfigIsPublished?: boolean
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
agentName?: string | null
draftSavedAt?: number
isPublishing?: boolean
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
workflowReferencesEnabled?: boolean
onPublish?: () => void | Promise<void>
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<PublishBarMode>({ 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<AgentReferencingWorkflowsResponse>(
workflowReferencesQueryOptions.queryKey,
)
const references = workflowReferencesEnabled
? ((
cachedReferences ??
workflowReferencesQuery.data ??
(await queryClient.ensureQueryData(workflowReferencesQueryOptions))
)?.data ?? [])
: []
let referencesResponse: AgentReferencingWorkflowsResponse | undefined
try {
referencesResponse =
queryClient.getQueryData<AgentReferencingWorkflowsResponse>(
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}
/>
</Collapsible>
)
@ -360,7 +361,7 @@ function PublishBarActions({
canPublish: boolean
onCancelImpact: () => void
onOpenVersions: () => void
onPublishRequest: () => void | Promise<void>
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 && <span aria-hidden className={`${actionIcon} size-4 shrink-0`} />}
<span className="shrink-0">{actionLabel}</span>

View File

@ -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(
<QueryClientProvider client={queryClient}>

View File

@ -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<number | undefined>(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<string | undefined>(undefined)
const latestAppliedSaveSequenceRef = useRef(0)
const nextSaveSequenceRef = useRef(0)
const pageCloseSavingDraftKeyRef = useRef<string | undefined>(undefined)
const explicitlySavingDraftKeysRef = useRef(new Set<string>())
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,
}

View File

@ -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 () => {

View File

@ -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<typeof consoleClient> = 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<typeof consoleClient> = createTanstackQue
publish: {
post: {
mutationOptions: {
onSuccess: (_publishResult, _variables, _onMutateResult, context) => {
onSuccess: (publishResult, variables, _onMutateResult, context) => {
context.client.setQueryData<AgentAppComposerResponse>(
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(),
})