diff --git a/web/app/components/app/configuration/config-prompt/confirm-add-var/index.spec.tsx b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.spec.tsx
new file mode 100644
index 0000000000..7ffafbb172
--- /dev/null
+++ b/web/app/components/app/configuration/config-prompt/confirm-add-var/index.spec.tsx
@@ -0,0 +1,49 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import ConfirmAddVar from './index'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+jest.mock('../../base/var-highlight', () => ({
+ __esModule: true,
+ default: ({ name }: { name: string }) => {name},
+}))
+
+describe('ConfirmAddVar', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render variable names', () => {
+ render()
+
+ const highlights = screen.getAllByTestId('var-highlight')
+ expect(highlights).toHaveLength(2)
+ expect(highlights[0]).toHaveTextContent('foo')
+ expect(highlights[1]).toHaveTextContent('bar')
+ })
+
+ it('should trigger cancel actions', () => {
+ const onConfirm = jest.fn()
+ const onCancel = jest.fn()
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ expect(onCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should trigger confirm actions', () => {
+ const onConfirm = jest.fn()
+ const onCancel = jest.fn()
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.add'))
+
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.spec.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.spec.tsx
new file mode 100644
index 0000000000..652f5409e8
--- /dev/null
+++ b/web/app/components/app/configuration/config-prompt/conversation-history/edit-modal.spec.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import EditModal from './edit-modal'
+import type { ConversationHistoriesRole } from '@/models/debug'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+jest.mock('@/app/components/base/modal', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}))
+
+describe('Conversation history edit modal', () => {
+ const data: ConversationHistoriesRole = {
+ user_prefix: 'user',
+ assistant_prefix: 'assistant',
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render provided prefixes', () => {
+ render()
+
+ expect(screen.getByDisplayValue('user')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('assistant')).toBeInTheDocument()
+ })
+
+ it('should update prefixes and save changes', () => {
+ const onSave = jest.fn()
+ render()
+
+ fireEvent.change(screen.getByDisplayValue('user'), { target: { value: 'member' } })
+ fireEvent.change(screen.getByDisplayValue('assistant'), { target: { value: 'helper' } })
+ fireEvent.click(screen.getByText('common.operation.save'))
+
+ expect(onSave).toHaveBeenCalledWith({
+ user_prefix: 'member',
+ assistant_prefix: 'helper',
+ })
+ })
+
+ it('should call close handler', () => {
+ const onClose = jest.fn()
+ render()
+
+ fireEvent.click(screen.getByText('common.operation.cancel'))
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.spec.tsx b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.spec.tsx
new file mode 100644
index 0000000000..61e361c057
--- /dev/null
+++ b/web/app/components/app/configuration/config-prompt/conversation-history/history-panel.spec.tsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import HistoryPanel from './history-panel'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+const mockDocLink = jest.fn(() => 'doc-link')
+jest.mock('@/context/i18n', () => ({
+ useDocLink: () => mockDocLink,
+}))
+
+jest.mock('@/app/components/app/configuration/base/operation-btn', () => ({
+ __esModule: true,
+ default: ({ onClick }: { onClick: () => void }) => (
+
+ ),
+}))
+
+jest.mock('@/app/components/app/configuration/base/feature-panel', () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => {children}
,
+}))
+
+describe('HistoryPanel', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render warning content and link when showWarning is true', () => {
+ render()
+
+ expect(screen.getByText('appDebug.feature.conversationHistory.tip')).toBeInTheDocument()
+ const link = screen.getByText('appDebug.feature.conversationHistory.learnMore')
+ expect(link).toHaveAttribute('href', 'doc-link')
+ })
+
+ it('should hide warning when showWarning is false', () => {
+ render()
+
+ expect(screen.queryByText('appDebug.feature.conversationHistory.tip')).toBeNull()
+ })
+})
diff --git a/web/app/components/app/configuration/config-prompt/index.spec.tsx b/web/app/components/app/configuration/config-prompt/index.spec.tsx
new file mode 100644
index 0000000000..b2098862da
--- /dev/null
+++ b/web/app/components/app/configuration/config-prompt/index.spec.tsx
@@ -0,0 +1,351 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import Prompt, { type IPromptProps } from './index'
+import ConfigContext from '@/context/debug-configuration'
+import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
+import { type PromptItem, PromptRole, type PromptVariable } from '@/models/debug'
+import { AppModeEnum, ModelModeType } from '@/types/app'
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+type DebugConfiguration = {
+ isAdvancedMode: boolean
+ currentAdvancedPrompt: PromptItem | PromptItem[]
+ setCurrentAdvancedPrompt: (prompt: PromptItem | PromptItem[], isUserChanged?: boolean) => void
+ modelModeType: ModelModeType
+ dataSets: Array<{
+ id: string
+ name?: string
+ }>
+ hasSetBlockStatus: {
+ context: boolean
+ history: boolean
+ query: boolean
+ }
+}
+
+const defaultPromptVariables: PromptVariable[] = [
+ { key: 'var', name: 'Variable', type: 'string', required: true },
+]
+
+let mockSimplePromptInputProps: IPromptProps | null = null
+
+jest.mock('./simple-prompt-input', () => ({
+ __esModule: true,
+ default: (props: IPromptProps) => {
+ mockSimplePromptInputProps = props
+ return (
+ props.onChange?.('mocked prompt', props.promptVariables)}
+ >
+ SimplePromptInput Mock
+
+ )
+ },
+}))
+
+type AdvancedMessageInputProps = {
+ isChatMode: boolean
+ type: PromptRole
+ value: string
+ onTypeChange: (value: PromptRole) => void
+ canDelete: boolean
+ onDelete: () => void
+ onChange: (value: string) => void
+ promptVariables: PromptVariable[]
+ isContextMissing: boolean
+ onHideContextMissingTip: () => void
+ noResize?: boolean
+}
+
+jest.mock('./advanced-prompt-input', () => ({
+ __esModule: true,
+ default: (props: AdvancedMessageInputProps) => {
+ return (
+
+
+
+
+
+
+ )
+ },
+}))
+const getContextValue = (overrides: Partial = {}): DebugConfiguration => {
+ return {
+ setCurrentAdvancedPrompt: jest.fn(),
+ isAdvancedMode: false,
+ currentAdvancedPrompt: [],
+ modelModeType: ModelModeType.chat,
+ dataSets: [],
+ hasSetBlockStatus: {
+ context: false,
+ history: false,
+ query: false,
+ },
+ ...overrides,
+ }
+}
+
+const renderComponent = (
+ props: Partial = {},
+ contextOverrides: Partial = {},
+) => {
+ const mergedProps: IPromptProps = {
+ mode: AppModeEnum.CHAT,
+ promptTemplate: 'initial template',
+ promptVariables: defaultPromptVariables,
+ onChange: jest.fn(),
+ ...props,
+ }
+ const contextValue = getContextValue(contextOverrides)
+
+ return {
+ contextValue,
+ ...render(
+
+
+ ,
+ ),
+ }
+}
+
+describe('Prompt config component', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockSimplePromptInputProps = null
+ })
+
+ // Rendering simple mode
+ it('should render simple prompt when advanced mode is disabled', () => {
+ const onChange = jest.fn()
+ renderComponent({ onChange }, { isAdvancedMode: false })
+
+ const simplePrompt = screen.getByTestId('simple-prompt-input')
+ expect(simplePrompt).toBeInTheDocument()
+ expect(simplePrompt).toHaveAttribute('data-mode', AppModeEnum.CHAT)
+ expect(mockSimplePromptInputProps?.promptTemplate).toBe('initial template')
+ fireEvent.click(simplePrompt)
+ expect(onChange).toHaveBeenCalledWith('mocked prompt', defaultPromptVariables)
+ expect(screen.queryByTestId('advanced-message-input')).toBeNull()
+ })
+
+ // Rendering advanced chat messages
+ it('should render advanced chat prompts and show context missing tip when dataset context is not set', () => {
+ const currentAdvancedPrompt: PromptItem[] = [
+ { role: PromptRole.user, text: 'first' },
+ { role: PromptRole.assistant, text: 'second' },
+ ]
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt,
+ modelModeType: ModelModeType.chat,
+ dataSets: [{ id: 'ds' } as unknown as DebugConfiguration['dataSets'][number]],
+ hasSetBlockStatus: { context: false, history: true, query: true },
+ },
+ )
+
+ const renderedMessages = screen.getAllByTestId('advanced-message-input')
+ expect(renderedMessages).toHaveLength(2)
+ expect(renderedMessages[0]).toHaveAttribute('data-context-missing', 'true')
+ fireEvent.click(screen.getAllByText('hide-context')[0])
+ expect(screen.getAllByTestId('advanced-message-input')[0]).toHaveAttribute('data-context-missing', 'false')
+ })
+
+ // Chat message mutations
+ it('should update chat prompt value and call setter with user change flag', () => {
+ const currentAdvancedPrompt: PromptItem[] = [
+ { role: PromptRole.user, text: 'first' },
+ { role: PromptRole.assistant, text: 'second' },
+ ]
+ const setCurrentAdvancedPrompt = jest.fn()
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt,
+ modelModeType: ModelModeType.chat,
+ setCurrentAdvancedPrompt,
+ },
+ )
+
+ fireEvent.click(screen.getAllByText('change')[0])
+ expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith(
+ [
+ { role: PromptRole.user, text: 'updated text' },
+ { role: PromptRole.assistant, text: 'second' },
+ ],
+ true,
+ )
+ })
+
+ it('should update chat prompt role when type changes', () => {
+ const currentAdvancedPrompt: PromptItem[] = [
+ { role: PromptRole.user, text: 'first' },
+ { role: PromptRole.user, text: 'second' },
+ ]
+ const setCurrentAdvancedPrompt = jest.fn()
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt,
+ modelModeType: ModelModeType.chat,
+ setCurrentAdvancedPrompt,
+ },
+ )
+
+ fireEvent.click(screen.getAllByText('type')[1])
+ expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith(
+ [
+ { role: PromptRole.user, text: 'first' },
+ { role: PromptRole.assistant, text: 'second' },
+ ],
+ )
+ })
+
+ it('should delete chat prompt item', () => {
+ const currentAdvancedPrompt: PromptItem[] = [
+ { role: PromptRole.user, text: 'first' },
+ { role: PromptRole.assistant, text: 'second' },
+ ]
+ const setCurrentAdvancedPrompt = jest.fn()
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt,
+ modelModeType: ModelModeType.chat,
+ setCurrentAdvancedPrompt,
+ },
+ )
+
+ fireEvent.click(screen.getAllByText('delete')[0])
+ expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([{ role: PromptRole.assistant, text: 'second' }])
+ })
+
+ // Add message behavior
+ it('should append a mirrored role message when clicking add in chat mode', () => {
+ const currentAdvancedPrompt: PromptItem[] = [
+ { role: PromptRole.user, text: 'first' },
+ ]
+ const setCurrentAdvancedPrompt = jest.fn()
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt,
+ modelModeType: ModelModeType.chat,
+ setCurrentAdvancedPrompt,
+ },
+ )
+
+ fireEvent.click(screen.getByText('appDebug.promptMode.operation.addMessage'))
+ expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([
+ { role: PromptRole.user, text: 'first' },
+ { role: PromptRole.assistant, text: '' },
+ ])
+ })
+
+ it('should append a user role when the last chat prompt is from assistant', () => {
+ const currentAdvancedPrompt: PromptItem[] = [
+ { role: PromptRole.assistant, text: 'reply' },
+ ]
+ const setCurrentAdvancedPrompt = jest.fn()
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt,
+ modelModeType: ModelModeType.chat,
+ setCurrentAdvancedPrompt,
+ },
+ )
+
+ fireEvent.click(screen.getByText('appDebug.promptMode.operation.addMessage'))
+ expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([
+ { role: PromptRole.assistant, text: 'reply' },
+ { role: PromptRole.user, text: '' },
+ ])
+ })
+
+ it('should insert a system message when adding to an empty chat prompt list', () => {
+ const setCurrentAdvancedPrompt = jest.fn()
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt: [],
+ modelModeType: ModelModeType.chat,
+ setCurrentAdvancedPrompt,
+ },
+ )
+
+ fireEvent.click(screen.getByText('appDebug.promptMode.operation.addMessage'))
+ expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith([{ role: PromptRole.system, text: '' }])
+ })
+
+ it('should not show add button when reaching max prompt length', () => {
+ const prompts: PromptItem[] = Array.from({ length: MAX_PROMPT_MESSAGE_LENGTH }, (_, index) => ({
+ role: PromptRole.user,
+ text: `item-${index}`,
+ }))
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt: prompts,
+ modelModeType: ModelModeType.chat,
+ },
+ )
+
+ expect(screen.queryByText('appDebug.promptMode.operation.addMessage')).toBeNull()
+ })
+
+ // Completion mode
+ it('should update completion prompt value and flag as user change', () => {
+ const setCurrentAdvancedPrompt = jest.fn()
+ renderComponent(
+ {},
+ {
+ isAdvancedMode: true,
+ currentAdvancedPrompt: { role: PromptRole.user, text: 'single' },
+ modelModeType: ModelModeType.completion,
+ setCurrentAdvancedPrompt,
+ },
+ )
+
+ fireEvent.click(screen.getByText('change'))
+
+ expect(setCurrentAdvancedPrompt).toHaveBeenCalledWith({ role: PromptRole.user, text: 'updated text' }, true)
+ })
+})
diff --git a/web/app/components/app/configuration/config-prompt/message-type-selector.spec.tsx b/web/app/components/app/configuration/config-prompt/message-type-selector.spec.tsx
new file mode 100644
index 0000000000..4401b7e57e
--- /dev/null
+++ b/web/app/components/app/configuration/config-prompt/message-type-selector.spec.tsx
@@ -0,0 +1,37 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import MessageTypeSelector from './message-type-selector'
+import { PromptRole } from '@/models/debug'
+
+describe('MessageTypeSelector', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render current value and keep options hidden by default', () => {
+ render()
+
+ expect(screen.getByText(PromptRole.user)).toBeInTheDocument()
+ expect(screen.queryByText(PromptRole.system)).toBeNull()
+ })
+
+ it('should toggle option list when clicking the selector', () => {
+ render()
+
+ fireEvent.click(screen.getByText(PromptRole.system))
+
+ expect(screen.getByText(PromptRole.user)).toBeInTheDocument()
+ expect(screen.getByText(PromptRole.assistant)).toBeInTheDocument()
+ })
+
+ it('should call onChange with selected type and close the list', () => {
+ const onChange = jest.fn()
+ render()
+
+ fireEvent.click(screen.getByText(PromptRole.assistant))
+ fireEvent.click(screen.getByText(PromptRole.user))
+
+ expect(onChange).toHaveBeenCalledWith(PromptRole.user)
+ expect(screen.queryByText(PromptRole.system)).toBeNull()
+ })
+})
diff --git a/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.spec.tsx b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.spec.tsx
new file mode 100644
index 0000000000..d6bef4cdd7
--- /dev/null
+++ b/web/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap.spec.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
+
+describe('PromptEditorHeightResizeWrap', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers()
+ jest.useRealTimers()
+ })
+
+ it('should render children, footer, and hide resize handler when requested', () => {
+ const { container } = render(
+ footer}
+ hideResize
+ >
+ content
+ ,
+ )
+
+ expect(screen.getByText('content')).toBeInTheDocument()
+ expect(screen.getByText('footer')).toBeInTheDocument()
+ expect(container.querySelector('.cursor-row-resize')).toBeNull()
+ })
+
+ it('should resize height with mouse events and clamp to minHeight', () => {
+ const onHeightChange = jest.fn()
+
+ const { container } = render(
+
+ content
+ ,
+ )
+
+ const handle = container.querySelector('.cursor-row-resize')
+ expect(handle).not.toBeNull()
+
+ fireEvent.mouseDown(handle as Element, { clientY: 100 })
+ expect(document.body.style.userSelect).toBe('none')
+
+ fireEvent.mouseMove(document, { clientY: 130 })
+ jest.runAllTimers()
+ expect(onHeightChange).toHaveBeenLastCalledWith(180)
+
+ onHeightChange.mockClear()
+ fireEvent.mouseMove(document, { clientY: -100 })
+ jest.runAllTimers()
+ expect(onHeightChange).toHaveBeenLastCalledWith(100)
+
+ fireEvent.mouseUp(document)
+ expect(document.body.style.userSelect).toBe('')
+ })
+})