fix: keep agent conversation opener state in sync after saving (#42175)

This commit is contained in:
Joel authored and GitHub committed 2026-09-11 03:45:46 +00:00
1 parent cd61738f33
commit 8e022cc3c1
2 files changed
+124 -6

No files matched your search

@@ -0,0 +1,111 @@
import type { AgentSoulAppFeaturesConfig } from '@dify/contracts/api/console/agent/types.gen'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider } from 'jotai'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { useState } from 'react'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { agentComposerAppFeaturesAtom } from '@/features/agent-v2/agent-composer/store-modules/app-features'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AgentChatFeaturesPanel } from '../chat-features-panel'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: () => ({ data: undefined }),
}))
vi.mock('@/context/hooks/use-trigger-events-limit-modal', () => ({
useTriggerEventsLimitModal: () => ({ triggerEventsLimitModal: null }),
}))
// Lexical's browser editing engine is outside the feature configuration contract.
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea
aria-label="Opening statement"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}))
function PanelHarness({
snapshot,
disabled = false,
}: {
snapshot: AgentSoulAppFeaturesConfig
disabled?: boolean
}) {
const [show, setShow] = useState(true)
return (
<>
<button onClick={() => setShow(true)}>Open features</button>
<AgentChatFeaturesPanel
appFeatures={snapshot}
disabled={disabled}
show={show}
onClose={() => setShow(false)}
/>
</>
)
}
function renderPanel(snapshot: AgentSoulAppFeaturesConfig, draft = snapshot, disabled = false) {
const store = createStore()
store.set(agentComposerAppFeaturesAtom, draft)
const ui = (currentSnapshot: AgentSoulAppFeaturesConfig) => (
<Provider store={store}>
<NuqsTestingAdapter>
<ModalContextProvider>
<PanelHarness snapshot={currentSnapshot} disabled={disabled} />
</ModalContextProvider>
</NuqsTestingAdapter>
</Provider>
)
const rendered = renderWithConsoleQuery(ui(snapshot))
return {
...rendered,
updateSnapshot: (next: AgentSoulAppFeaturesConfig) => rendered.rerender(ui(next)),
}
}
describe('AgentChatFeaturesPanel', () => {
it('keeps a newly saved opener enabled before the server snapshot catches up', async () => {
const user = userEvent.setup({ skipHover: true })
const { updateSnapshot } = renderPanel({ opening_statement: '' })
await user.click(screen.getAllByRole('switch')[0]!)
await user.hover(screen.getByText('appDebug.feature.conversationOpener.title'))
await user.click(screen.getByRole('button', { name: 'appDebug.openingStatement.writeOpener' }))
const editor = await screen.findByRole('textbox')
await user.type(editor, 'Hello from the unsaved draft')
updateSnapshot({ opening_statement: '', suggested_questions: [] })
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(await screen.findByText('Hello from the unsaved draft')).toBeVisible()
expect(screen.getAllByRole('switch')[0]!).toBeChecked()
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('switch')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Open features' }))
expect(await screen.findByText('Hello from the unsaved draft')).toBeVisible()
expect(screen.getAllByRole('switch')[0]!).toBeChecked()
})
it('initializes editable features from the current draft instead of the stale snapshot', () => {
renderPanel({ opening_statement: '' }, { opening_statement: 'Current draft opener' })
expect(screen.getByText('Current draft opener')).toBeVisible()
expect(screen.getAllByRole('switch')[0]!).toBeChecked()
})
it('shows the selected snapshot in read-only mode instead of the editable draft', () => {
renderPanel(
{ opening_statement: 'Published opener' },
{ opening_statement: 'Current draft opener' },
true,
)
expect(screen.getByText('Published opener')).toBeVisible()
expect(screen.queryByText('Current draft opener')).not.toBeInTheDocument()
expect(screen.getAllByRole('switch')[0]!).toBeChecked()
expect(screen.getAllByRole('switch')[0]!).toHaveAttribute('aria-disabled', 'true')
})
})
@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next'
import { FeaturesProvider } from '@/app/components/base/features'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import { useSetAppFeatures } from '@/features/agent-v2/agent-composer/store-modules/app-features'
import {
useAppFeatures,
useSetAppFeatures,
} from '@/features/agent-v2/agent-composer/store-modules/app-features'
import { Resolution, TransferMethod } from '@/types/app'
type AgentChatFeaturesPanelProps = {
@@ -123,10 +126,7 @@ function toAppFeatures(
text_to_speech: features.text2speech as AgentSoulAppFeaturesConfig['text_to_speech'],
speech_to_text: features.speech2text,
retriever_resource: features.citation,
sensitive_word_avoidance:
features.moderation as AgentSoulAppFeaturesConfig['sensitive_word_avoidance'],
file_upload: toAgentFileUploadFeatureConfig(features.file),
annotation_reply: features.annotationReply,
}
}
@@ -167,9 +167,16 @@ function AgentChatFeaturesPanelContent({
)
}
export function AgentChatFeaturesPanel({ appFeatures, ...props }: AgentChatFeaturesPanelProps) {
export function AgentChatFeaturesPanel({
appFeatures: snapshotAppFeatures,
...props
}: AgentChatFeaturesPanelProps) {
const draftAppFeatures = useAppFeatures()
const appFeatures = props.disabled ? snapshotAppFeatures : draftAppFeatures
const features = useMemo(() => toPanelFeatures(appFeatures), [appFeatures])
const featuresKey = useMemo(() => JSON.stringify(appFeatures ?? {}), [appFeatures])
// Keep transient feature switches while editing, including an empty opener
// that has been enabled but has not had its statement saved yet.
const featuresKey = props.disabled ? JSON.stringify(appFeatures ?? {}) : 'draft'
return (
<FeaturesProvider key={featuresKey} features={features}>