mirror of
https://github.com/langgenius/dify.git
synced 2026-07-18 15:54:33 -04:00
chore: new agent enchance (#39040)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -78,12 +78,12 @@ The runner automatically creates `$CWD/.tmp` and sets `TMPDIR`, `TMP`, `TEMP` to
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SHELLCTL_ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely |
|
||||
| `SHELLCTL_LANDLOCK_RW_PATHS` | *(empty)* | Comma-separated RW directories (besides `$HOME`) |
|
||||
| `SHELLCTL_LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories |
|
||||
| `SHELLCTL_LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access |
|
||||
| Variable | Default | Description |
|
||||
| -------------------------------- | --------------- | ------------------------------------------------ |
|
||||
| `SHELLCTL_ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely |
|
||||
| `SHELLCTL_LANDLOCK_RW_PATHS` | _(empty)_ | Comma-separated RW directories (besides `$HOME`) |
|
||||
| `SHELLCTL_LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories |
|
||||
| `SHELLCTL_LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access |
|
||||
|
||||
Requires Linux ≥ 5.13. On unsupported kernels, a warning is printed to stderr.
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import { AuthCategory, CredentialTypeEnum } from '../types'
|
||||
// ==================== Mock Setup ====================
|
||||
|
||||
const mockGetPluginCredentialInfo = vi.fn()
|
||||
const mockIsPluginCredentialInfoLoading = vi.fn()
|
||||
const mockGetPluginOAuthClientSchema = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-plugins-auth', () => ({
|
||||
useGetPluginCredentialInfo: (url: string) => ({
|
||||
data: url ? mockGetPluginCredentialInfo() : undefined,
|
||||
isLoading: false,
|
||||
isLoading: mockIsPluginCredentialInfoLoading(),
|
||||
}),
|
||||
useDeletePluginCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
useSetPluginDefaultCredential: () => ({ mutateAsync: vi.fn() }),
|
||||
@@ -85,6 +86,7 @@ describe('AuthorizedInNode Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||
mockIsPluginCredentialInfoLoading.mockReturnValue(false)
|
||||
mockGetPluginCredentialInfo.mockReturnValue({
|
||||
credentials: [createCredential({ is_default: true })],
|
||||
supported_credential_types: [CredentialTypeEnum.API_KEY],
|
||||
@@ -163,6 +165,25 @@ describe('AuthorizedInNode Component', () => {
|
||||
expect(screen.getByText('plugin.auth.authRemoved'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show auth removed while credential info is loading', async () => {
|
||||
const AuthorizedInNode = (await import('../authorized-in-node')).default
|
||||
mockIsPluginCredentialInfoLoading.mockReturnValue(true)
|
||||
mockGetPluginCredentialInfo.mockReturnValue(undefined)
|
||||
const pluginPayload = createPluginPayload()
|
||||
|
||||
render(
|
||||
<AuthorizedInNode
|
||||
pluginPayload={pluginPayload}
|
||||
onAuthorizationItemClick={vi.fn()}
|
||||
credentialId="new-credential-id"
|
||||
/>,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
expect(screen.queryByText('plugin.auth.authRemoved')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show unavailable when credential is not allowed', async () => {
|
||||
const AuthorizedInNode = (await import('../authorized-in-node')).default
|
||||
const credential = createCredential({
|
||||
|
||||
@@ -26,6 +26,7 @@ const AuthorizedInNode = ({
|
||||
canApiKey,
|
||||
canOAuth,
|
||||
credentials,
|
||||
isLoading,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth(pluginPayload, true, credentialId ? [credentialId] : undefined)
|
||||
@@ -53,8 +54,14 @@ const AuthorizedInNode = ({
|
||||
}
|
||||
} else {
|
||||
const credential = credentials.find((c) => c.id === credentialId)
|
||||
label = credential ? credential.name : t(($) => $['auth.authRemoved'], { ns: 'plugin' })
|
||||
removed = !credential
|
||||
if (credential) label = credential.name
|
||||
else if (isLoading) {
|
||||
label = t(($) => $.loading, { ns: 'common' })
|
||||
color = 'disabled'
|
||||
} else {
|
||||
label = t(($) => $['auth.authRemoved'], { ns: 'plugin' })
|
||||
removed = true
|
||||
}
|
||||
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
|
||||
|
||||
if (removed) color = 'error'
|
||||
@@ -86,7 +93,7 @@ const AuthorizedInNode = ({
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
[credentialId, credentials, t],
|
||||
[credentialId, credentials, isLoading, t],
|
||||
)
|
||||
const defaultUnavailable = credentials.find((c) => c.is_default)?.not_allowed_to_use
|
||||
const extraAuthorizationItems: Credential[] = [
|
||||
|
||||
@@ -10,7 +10,11 @@ export const usePluginAuth = (
|
||||
enable?: boolean,
|
||||
includeCredentialIds?: string[],
|
||||
) => {
|
||||
const { data } = useGetPluginCredentialInfoHook(pluginPayload, enable, includeCredentialIds)
|
||||
const { data, isLoading } = useGetPluginCredentialInfoHook(
|
||||
pluginPayload,
|
||||
enable,
|
||||
includeCredentialIds,
|
||||
)
|
||||
const isAuthorized = !!data?.credentials.length
|
||||
const canOAuth = data?.supported_credential_types.includes(CredentialTypeEnum.OAUTH2)
|
||||
const canApiKey = data?.supported_credential_types.includes(CredentialTypeEnum.API_KEY)
|
||||
@@ -20,6 +24,7 @@ export const usePluginAuth = (
|
||||
isAuthorized,
|
||||
canOAuth,
|
||||
canApiKey,
|
||||
isLoading,
|
||||
credentials: data?.credentials || [],
|
||||
notAllowCustomCredential: data?.allow_custom_token === false,
|
||||
invalidPluginCredentialInfo,
|
||||
|
||||
@@ -86,6 +86,7 @@ const mockAppComposerQueryOptions = vi.hoisted(() =>
|
||||
}
|
||||
}
|
||||
}) => number | false
|
||||
retry?: number
|
||||
}) => {
|
||||
const { input } = options
|
||||
|
||||
@@ -96,18 +97,37 @@ const mockAppComposerQueryOptions = vi.hoisted(() =>
|
||||
: ['workflow-agent-composer', input.params.app_id, input.params.node_id],
|
||||
queryFn: typeof input === 'symbol' ? input : mockAppComposerQueryFn,
|
||||
refetchInterval: options.refetchInterval,
|
||||
retry: options.retry,
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
const mockSnippetComposerQueryOptions = vi.hoisted(() =>
|
||||
vi.fn(({ input }: { input: symbol | { params: { snippet_id: string; node_id: string } } }) => ({
|
||||
queryKey:
|
||||
typeof input === 'symbol'
|
||||
? ['snippet-agent-composer-disabled']
|
||||
: ['snippet-agent-composer', input.params.snippet_id, input.params.node_id],
|
||||
queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn,
|
||||
})),
|
||||
vi.fn(
|
||||
(options: {
|
||||
input: symbol | { params: { snippet_id: string; node_id: string } }
|
||||
refetchInterval?: (query: {
|
||||
state: {
|
||||
data?: {
|
||||
agent?: unknown
|
||||
}
|
||||
}
|
||||
}) => number | false
|
||||
retry?: number
|
||||
}) => {
|
||||
const { input } = options
|
||||
|
||||
return {
|
||||
queryKey:
|
||||
typeof input === 'symbol'
|
||||
? ['snippet-agent-composer-disabled']
|
||||
: ['snippet-agent-composer', input.params.snippet_id, input.params.node_id],
|
||||
queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn,
|
||||
refetchInterval: options.refetchInterval,
|
||||
retry: options.retry,
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@@ -187,6 +207,10 @@ vi.mock('@/service/client', () => ({
|
||||
describe('useWorkflowInlineAgentDetail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppComposerQueryFn.mockReset()
|
||||
mockAppComposerQueryFn.mockResolvedValue({ agent: { id: 'app-agent' } })
|
||||
mockSnippetComposerQueryFn.mockReset()
|
||||
mockSnippetComposerQueryFn.mockResolvedValue({ agent: { id: 'snippet-agent' } })
|
||||
})
|
||||
|
||||
it('loads inline agent detail through the snippet composer API', async () => {
|
||||
@@ -255,6 +279,41 @@ describe('useWorkflowInlineAgentDetail', () => {
|
||||
})
|
||||
expect(result.current.data).toEqual({ agent: { id: 'app-agent' } })
|
||||
})
|
||||
|
||||
it('retries five times and stops polling when loading the copied inline agent composer fails', async () => {
|
||||
mockAppComposerQueryFn.mockRejectedValue(new Error('Agent composer was not created'))
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
retryDelay: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(
|
||||
() =>
|
||||
useWorkflowInlineAgentDetail('copied-node', 'source-inline-agent', {
|
||||
pollUntilReady: true,
|
||||
}),
|
||||
{
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
})
|
||||
|
||||
expect(mockAppComposerQueryFn).toHaveBeenCalledTimes(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useCreateInlineAgentBinding', () => {
|
||||
|
||||
@@ -1023,6 +1023,37 @@ describe('agent/panel', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retry action when loading the inline agent composer fails', () => {
|
||||
mockUseWorkflowInlineAgentDetail.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: true,
|
||||
isFetching: false,
|
||||
refetch: mockWorkflowInlineAgentDetailRefetch,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<AgentV2Panel
|
||||
id="agent-node"
|
||||
data={createData({
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'snapshot-1',
|
||||
},
|
||||
})}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'agentV2.roster.nodeSelector.createInlineFailed',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
expect(mockWorkflowInlineAgentDetailRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('recovers the inline setup panel open state from the node open marker', () => {
|
||||
mockUseWorkflowInlineAgentDetail.mockReturnValue({ data: undefined })
|
||||
|
||||
|
||||
@@ -329,11 +329,13 @@ export function AgentRosterField({
|
||||
agent,
|
||||
agentId,
|
||||
canOpenPanel = true,
|
||||
errorMessage,
|
||||
isPanelOpen,
|
||||
isPanelCopyPending = false,
|
||||
isPending = false,
|
||||
isLoading = false,
|
||||
isInlineSetup = false,
|
||||
isRetrying = false,
|
||||
panelBody,
|
||||
panelMode = 'detail',
|
||||
showPanelDetailActions = true,
|
||||
@@ -341,16 +343,19 @@ export function AgentRosterField({
|
||||
onChange,
|
||||
onMakeCopy,
|
||||
onPanelOpenChange,
|
||||
onRetry,
|
||||
onSaveInlineToRoster,
|
||||
onStartFromScratch,
|
||||
}: {
|
||||
agent?: AgentRosterDisplayData
|
||||
agentId?: string
|
||||
canOpenPanel?: boolean
|
||||
errorMessage?: string
|
||||
isPanelOpen?: boolean
|
||||
isPanelCopyPending?: boolean
|
||||
isLoading?: boolean
|
||||
isInlineSetup?: boolean
|
||||
isRetrying?: boolean
|
||||
isPending?: boolean
|
||||
panelBody?: ReactNode
|
||||
panelMode?: AgentRosterDrawerMode
|
||||
@@ -359,6 +364,7 @@ export function AgentRosterField({
|
||||
onChange: (agent: AgentRosterNodeData) => void
|
||||
onMakeCopy?: () => void
|
||||
onPanelOpenChange?: (open: boolean) => void
|
||||
onRetry?: () => void
|
||||
onSaveInlineToRoster?: () => void
|
||||
onStartFromScratch?: () => void
|
||||
}) {
|
||||
@@ -464,7 +470,30 @@ export function AgentRosterField({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{agent ? (
|
||||
{errorMessage ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-13 w-full min-w-0 items-center gap-2 rounded-[10px] border-[0.5px] border-state-destructive-border bg-components-panel-on-panel-item-bg py-2 pr-2 pl-2 text-left shadow-xs shadow-shadow-shadow-3"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-state-destructive-hover text-text-destructive">
|
||||
<span aria-hidden className="i-ri-error-warning-line size-4" />
|
||||
</span>
|
||||
<span className="line-clamp-2 min-w-0 flex-1 system-xs-medium text-text-destructive">
|
||||
{errorMessage}
|
||||
</span>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="shrink-0"
|
||||
loading={isRetrying}
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t(($) => $['operation.retry'], { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : agent ? (
|
||||
canOpenPanel ? (
|
||||
<>
|
||||
{isInlineSetup ? (
|
||||
|
||||
@@ -21,6 +21,7 @@ type CreateInlineAgentBindingOptions = {
|
||||
}
|
||||
|
||||
const INLINE_AGENT_CREATION_REFETCH_INTERVAL = 1000
|
||||
const INLINE_AGENT_CREATION_RETRY_COUNT = 5
|
||||
|
||||
export function useAgentRosterDetail(agentId?: string) {
|
||||
return useQuery(
|
||||
@@ -46,13 +47,18 @@ export function useWorkflowInlineAgentDetail(
|
||||
const configsMap = useHooksStore((state) => state.configsMap)
|
||||
const refetchUntilReady = options?.pollUntilReady
|
||||
? {
|
||||
retry: INLINE_AGENT_CREATION_RETRY_COUNT,
|
||||
refetchInterval: (query: {
|
||||
state: {
|
||||
data?: {
|
||||
agent?: unknown
|
||||
}
|
||||
status: 'error' | 'pending' | 'success'
|
||||
}
|
||||
}) => (query.state.data?.agent ? false : INLINE_AGENT_CREATION_REFETCH_INTERVAL),
|
||||
}) =>
|
||||
query.state.status === 'error' || query.state.data?.agent
|
||||
? false
|
||||
: INLINE_AGENT_CREATION_REFETCH_INTERVAL,
|
||||
}
|
||||
: {}
|
||||
|
||||
|
||||
@@ -159,7 +159,9 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
|
||||
? inlineAgentBinding.agent_id
|
||||
: sourceInlineAgentId
|
||||
const isInlineAgentCreated = isInlineAgentReady && !!inlineAgent
|
||||
const isInlineAgentWaitingForCreation = isInlineAgentReady && !isInlineAgentCreated
|
||||
const isInlineAgentLoadError = isInlineAgentReady && inlineAgentQuery.isError
|
||||
const isInlineAgentWaitingForCreation =
|
||||
isInlineAgentReady && !isInlineAgentCreated && !isInlineAgentLoadError
|
||||
const isInlineAgentPanelOpen =
|
||||
(isInlineAgentCreated || isInlineAgentPending) && openInlineAgentPanelNodeId === id
|
||||
const { isPending: isAppCopyingFromRoster, mutate: copyFromRosterApp } = useMutation(
|
||||
@@ -619,14 +621,22 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
|
||||
/>
|
||||
<div className="border-b border-divider-subtle">
|
||||
<AgentRosterField
|
||||
agent={isInlineAgentWaitingForCreation ? undefined : displayedAgent}
|
||||
agent={
|
||||
isInlineAgentWaitingForCreation || isInlineAgentLoadError ? undefined : displayedAgent
|
||||
}
|
||||
agentId={rosterAgentId ?? inlineAgentId ?? (isInlineAgentPending ? id : undefined)}
|
||||
canOpenPanel={!isInlineAgentWaitingForCreation}
|
||||
errorMessage={
|
||||
isInlineAgentLoadError
|
||||
? t(($) => $['roster.nodeSelector.createInlineFailed'], { ns: 'agentV2' })
|
||||
: undefined
|
||||
}
|
||||
isInlineSetup={isInlineAgentReady || isInlineAgentPending}
|
||||
isLoading={isInlineAgentLoading}
|
||||
isPanelCopyPending={isCopyingFromRoster}
|
||||
isPanelOpen={isAgentPanelOpen}
|
||||
isPending={isAgentBindingPending}
|
||||
isRetrying={isInlineAgentLoadError && inlineAgentQuery.isFetching}
|
||||
panelBody={
|
||||
isAgentPanelOpen && displayedAgent ? (
|
||||
isInlineAgentReady || isInlineAgentPending ? (
|
||||
@@ -662,6 +672,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps<AgentV2NodeType>) {
|
||||
onChange={handleRosterChange}
|
||||
onMakeCopy={rosterAgentId ? handleMakeRosterCopy : undefined}
|
||||
onPanelOpenChange={handleAgentPanelOpenChange}
|
||||
onRetry={isInlineAgentLoadError ? () => void inlineAgentQuery.refetch() : undefined}
|
||||
onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined}
|
||||
onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined}
|
||||
/>
|
||||
|
||||
@@ -176,6 +176,10 @@ describe('isAgentSuggestedModel', () => {
|
||||
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5-pro'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-sol'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-terra'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.6-luna'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('Claude Opus 4.8'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('opus-4.7'))).toBe(true)
|
||||
expect(isAgentSuggestedModel(provider, createModelItem('Claude Sonnet 4.6'))).toBe(true)
|
||||
|
||||
+78
-3
@@ -1,4 +1,4 @@
|
||||
import type { AddOAuthButtonProps } from '@/app/components/plugins/plugin-auth/types'
|
||||
import type { AddOAuthButtonProps, Credential } from '@/app/components/plugins/plugin-auth/types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -6,6 +6,7 @@ import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
@@ -21,6 +22,13 @@ import { AgentTools } from '../index'
|
||||
const toolProviderState = vi.hoisted(() => ({
|
||||
builtInTools: [] as ToolWithProvider[],
|
||||
}))
|
||||
const pluginAuthState = vi.hoisted(() => ({
|
||||
canOAuth: true as boolean | undefined,
|
||||
canApiKey: false as boolean | undefined,
|
||||
credentials: [] as Credential[],
|
||||
notAllowCustomCredential: false,
|
||||
invalidPluginCredentialInfo: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
|
||||
ToolPickerContent: () => <div>Mock tool picker</div>,
|
||||
@@ -51,6 +59,21 @@ vi.mock('@/app/components/plugins/plugin-auth/authorize/add-oauth-button', () =>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth/hooks/use-plugin-auth', () => ({
|
||||
usePluginAuth: () => ({
|
||||
...pluginAuthState,
|
||||
isAuthorized: pluginAuthState.credentials.length > 0,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-credential-permissions', () => ({
|
||||
useCredentialPermissions: () => ({
|
||||
canUseCredential: true,
|
||||
canCreateCredential: true,
|
||||
canManageCredential: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
|
||||
default: ({
|
||||
formSchemas,
|
||||
@@ -333,6 +356,10 @@ describe('AgentTools', () => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
toolProviderState.builtInTools = []
|
||||
pluginAuthState.canOAuth = true
|
||||
pluginAuthState.canApiKey = false
|
||||
pluginAuthState.credentials = []
|
||||
pluginAuthState.notAllowCustomCredential = false
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
@@ -521,7 +548,8 @@ describe('AgentTools', () => {
|
||||
expect(store.get(isAgentComposerDirtyAtom)).toBe(false)
|
||||
})
|
||||
|
||||
it('should show authorization action for reflected OAuth provider tools with unauthorized credential type', () => {
|
||||
it('should open authorization actions for reflected OAuth provider tools', async () => {
|
||||
const user = userEvent.setup()
|
||||
toolProviderState.builtInTools = [
|
||||
{
|
||||
...googleProvider,
|
||||
@@ -537,7 +565,54 @@ describe('AgentTools', () => {
|
||||
name: 'tools.notAuthorized',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'plugin.auth.useOAuthAuth' }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'tools.notAuthorized' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'plugin.auth.useOAuthAuth',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should bind an existing credential selected from the unauthorized status', async () => {
|
||||
const user = userEvent.setup()
|
||||
toolProviderState.builtInTools = [
|
||||
{
|
||||
...googleProvider,
|
||||
allow_delete: true,
|
||||
is_team_authorization: false,
|
||||
team_credentials: {},
|
||||
},
|
||||
]
|
||||
pluginAuthState.credentials = [
|
||||
{
|
||||
id: 'workspace-oauth',
|
||||
name: 'Workspace OAuth',
|
||||
provider: 'google',
|
||||
credential_type: CredentialTypeEnum.OAUTH2,
|
||||
is_default: true,
|
||||
},
|
||||
]
|
||||
const { store } = renderAgentToolsWithStore(reflectedUnauthorizedOAuthCredentialTypeDraft)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'tools.notAuthorized' }))
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).tools[0]).toMatchObject({
|
||||
credentialType: 'unauthorized',
|
||||
credentialVariant: 'none',
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('Workspace OAuth'))
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).tools[0]).toMatchObject({
|
||||
credentialId: 'workspace-oauth',
|
||||
credentialType: 'oauth2',
|
||||
credentialVariant: 'authorized',
|
||||
})
|
||||
})
|
||||
|
||||
it('should open provider tool settings with catalog icon and parameters', async () => {
|
||||
|
||||
+47
-45
@@ -17,11 +17,8 @@ import {
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
|
||||
import AddOAuthButton from '@/app/components/plugins/plugin-auth/authorize/add-oauth-button'
|
||||
import ApiKeyModal from '@/app/components/plugins/plugin-auth/authorize/api-key-modal'
|
||||
import { AuthCategory, Authorized, usePluginAuth } from '@/app/components/plugins/plugin-auth'
|
||||
import AuthorizedInNode from '@/app/components/plugins/plugin-auth/authorized-in-node'
|
||||
import { useInvalidPluginCredentialInfoHook } from '@/app/components/plugins/plugin-auth/hooks/use-credential'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -58,7 +55,7 @@ function UnauthorizedCredentialStatus({
|
||||
) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const pluginPayload = useMemo(
|
||||
() => ({
|
||||
provider: tool.id,
|
||||
@@ -67,53 +64,58 @@ function UnauthorizedCredentialStatus({
|
||||
}),
|
||||
[tool.id, tool.providerType],
|
||||
)
|
||||
const invalidPluginCredentialInfo = useInvalidPluginCredentialInfoHook(pluginPayload)
|
||||
const handleApiKeyModalOpen = useCallback(() => {
|
||||
setIsApiKeyModalOpen(true)
|
||||
}, [])
|
||||
const handleApiKeyModalClose = useCallback(() => {
|
||||
setIsApiKeyModalOpen(false)
|
||||
}, [])
|
||||
const {
|
||||
canApiKey,
|
||||
canOAuth,
|
||||
credentials,
|
||||
invalidPluginCredentialInfo,
|
||||
notAllowCustomCredential,
|
||||
} = usePluginAuth(pluginPayload, true)
|
||||
const handleCredentialUpdate = useCallback(() => {
|
||||
invalidPluginCredentialInfo()
|
||||
onCredentialChange(undefined, tool.credentialType)
|
||||
}, [invalidPluginCredentialInfo, onCredentialChange, tool.credentialType])
|
||||
|
||||
if (tool.credentialType === 'oauth2') {
|
||||
return (
|
||||
<AddOAuthButton
|
||||
pluginPayload={pluginPayload}
|
||||
onUpdate={handleCredentialUpdate}
|
||||
renderTrigger={({ disabled, onClick }) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{t(($) => $.notAuthorized, { ns: 'tools' })}
|
||||
<StatusDot className="ml-2" status="warning" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="small" className="shrink-0" onClick={handleApiKeyModalOpen}>
|
||||
const handleAuthorizationItemClick = useCallback(
|
||||
(id: string) => {
|
||||
const credential = credentials.find((item) => item.id === id)
|
||||
onCredentialChange(id, credential?.credential_type ?? tool.credentialType)
|
||||
setIsOpen(false)
|
||||
},
|
||||
[credentials, onCredentialChange, tool.credentialType],
|
||||
)
|
||||
const renderTrigger = useCallback(
|
||||
(open?: boolean) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
className={cn('shrink-0', open && 'bg-components-button-secondary-bg-hover')}
|
||||
>
|
||||
{t(($) => $.notAuthorized, { ns: 'tools' })}
|
||||
<StatusDot className="ml-2" status="warning" />
|
||||
</Button>
|
||||
<ApiKeyModal
|
||||
pluginPayload={pluginPayload}
|
||||
open={isApiKeyModalOpen}
|
||||
onOpenChange={setIsApiKeyModalOpen}
|
||||
onClose={handleApiKeyModalClose}
|
||||
onUpdate={handleCredentialUpdate}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
[t],
|
||||
)
|
||||
|
||||
return (
|
||||
<Authorized
|
||||
pluginPayload={pluginPayload}
|
||||
credentials={credentials}
|
||||
canOAuth={canOAuth}
|
||||
canApiKey={canApiKey}
|
||||
renderTrigger={renderTrigger}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
offset={4}
|
||||
placement="bottom-end"
|
||||
triggerPopupSameWidth={false}
|
||||
popupClassName="w-[360px]"
|
||||
disableSetDefault
|
||||
onItemClick={handleAuthorizationItemClick}
|
||||
showItemSelectedIcon
|
||||
onUpdate={handleCredentialUpdate}
|
||||
notAllowCustomCredential={notAllowCustomCredential}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ const agentSuggestedModelPatterns: RegExp[] = [
|
||||
// openai
|
||||
/^gpt[ .-]5\.5$/i,
|
||||
/^gpt[ .-]5\.5[ .-]pro$/i,
|
||||
/^gpt[ .-]5\.6(?:[ .-](?:sol|terra|luna))?$/i,
|
||||
|
||||
// anthropic
|
||||
/^(?:claude[ .-])?opus[ .-]4\.8$/i,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { DSLImportMode, DSLImportStatus } from '@/models/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useImportDSL } from './use-import-dsl'
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockImportDSL = vi.hoisted(() => vi.fn())
|
||||
const mockImportDSLConfirm = vi.hoisted(() => vi.fn())
|
||||
const mockHandleCheckPluginDependencies = vi.hoisted(() => vi.fn())
|
||||
const mockInvalidateAppList = vi.hoisted(() => vi.fn())
|
||||
const mockSetNeedRefresh = vi.hoisted(() => vi.fn())
|
||||
const mockGetRedirection = vi.hoisted(() => vi.fn())
|
||||
const mockResolveImportedAppRedirectionTarget = vi.hoisted(() => vi.fn())
|
||||
const mockAtoms = vi.hoisted(() => ({
|
||||
userProfileId: {},
|
||||
workspacePermissionKeys: {},
|
||||
}))
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMocks,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useSuspenseQuery: () => ({ data: { rbac_enabled: false } }),
|
||||
}))
|
||||
|
||||
vi.mock('jotai', () => ({
|
||||
useAtomValue: (atom: object) => {
|
||||
if (atom === mockAtoms.userProfileId) return 'user-1'
|
||||
if (atom === mockAtoms.workspacePermissionKeys) return ['app.create_and_management']
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/apps/storage', () => ({
|
||||
useSetNeedRefreshAppList: () => mockSetNeedRefresh,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/account-state', () => ({
|
||||
userProfileIdAtom: mockAtoms.userProfileId,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/permission-state', () => ({
|
||||
workspacePermissionKeysAtom: mockAtoms.workspacePermissionKeys,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/system-features/client', () => ({
|
||||
systemFeaturesQueryOptions: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
importDSL: (...args: unknown[]) => mockImportDSL(...args),
|
||||
importDSLConfirm: (...args: unknown[]) => mockImportDSLConfirm(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useInvalidateAppList: () => mockInvalidateAppList,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: (...args: unknown[]) => mockGetRedirection(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/imported-app-redirection', () => ({
|
||||
resolveImportedAppRedirectionTarget: (...args: unknown[]) =>
|
||||
mockResolveImportedAppRedirectionTarget(...args),
|
||||
}))
|
||||
|
||||
describe('useImportDSL', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockResolveImportedAppRedirectionTarget.mockImplementation(async (target) => target)
|
||||
})
|
||||
|
||||
it('should complete a confirmed import that returns warnings', async () => {
|
||||
const pendingResponse = {
|
||||
id: 'import-1',
|
||||
status: DSLImportStatus.PENDING,
|
||||
app_mode: AppModeEnum.AGENT,
|
||||
imported_dsl_version: '0.2.0',
|
||||
current_dsl_version: '0.1.0',
|
||||
permission_keys: [],
|
||||
}
|
||||
const completedResponse = {
|
||||
id: 'import-1',
|
||||
status: DSLImportStatus.COMPLETED_WITH_WARNINGS,
|
||||
app_id: 'app-1',
|
||||
app_mode: AppModeEnum.AGENT,
|
||||
permission_keys: ['app.acl.view_layout'],
|
||||
warnings: [
|
||||
{
|
||||
code: 'agent_file_omitted',
|
||||
path: 'agent.omitted_assets',
|
||||
message: 'Agent file was not included.',
|
||||
details: {},
|
||||
},
|
||||
],
|
||||
}
|
||||
const onPending = vi.fn()
|
||||
const onSuccess = vi.fn()
|
||||
const onFailed = vi.fn()
|
||||
mockImportDSL.mockResolvedValue(pendingResponse)
|
||||
mockImportDSLConfirm.mockResolvedValue(completedResponse)
|
||||
|
||||
const { result } = renderHook(() => useImportDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleImportDSL(
|
||||
{
|
||||
mode: DSLImportMode.YAML_CONTENT,
|
||||
yaml_content: 'app: demo',
|
||||
},
|
||||
{ onPending },
|
||||
)
|
||||
})
|
||||
await act(async () => {
|
||||
await result.current.handleImportDSLConfirm({ onSuccess, onFailed })
|
||||
})
|
||||
|
||||
expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-1' })
|
||||
expect(onSuccess).toHaveBeenCalledWith(completedResponse)
|
||||
expect(onFailed).not.toHaveBeenCalled()
|
||||
expect(toastMocks.warning).toHaveBeenCalledWith('app.newApp.caution', {
|
||||
description: 'app.newApp.appCreateDSLWarning',
|
||||
})
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('app-1')
|
||||
expect(mockSetNeedRefresh).toHaveBeenCalledWith('1')
|
||||
expect(mockInvalidateAppList).toHaveBeenCalledTimes(1)
|
||||
expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({
|
||||
id: 'app-1',
|
||||
mode: AppModeEnum.AGENT,
|
||||
permission_keys: ['app.acl.view_layout'],
|
||||
})
|
||||
expect(mockGetRedirection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -143,9 +143,22 @@ export const useImportDSL = () => {
|
||||
const { status, app_id, app_mode, permission_keys } = response
|
||||
if (!app_id) return
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) {
|
||||
if (
|
||||
status === DSLImportStatus.COMPLETED ||
|
||||
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
) {
|
||||
onSuccess?.(response)
|
||||
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
|
||||
const message = t(
|
||||
($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'],
|
||||
{ ns: 'app' },
|
||||
)
|
||||
const description =
|
||||
status === DSLImportStatus.COMPLETED_WITH_WARNINGS
|
||||
? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' })
|
||||
: undefined
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED) toast.success(message)
|
||||
else toast.warning(message, { description })
|
||||
await handleCheckPluginDependencies(app_id)
|
||||
setNeedRefresh('1')
|
||||
invalidateAppList()
|
||||
|
||||
@@ -184,6 +184,59 @@ describe('handleStream', () => {
|
||||
expect(onCompleted).toHaveBeenCalledWith(true, 'Bad request')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'an error event',
|
||||
payload: {
|
||||
event: 'error',
|
||||
message: 'Stream failed',
|
||||
code: 'stream_failed',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'a numeric error status',
|
||||
payload: {
|
||||
event: 'message',
|
||||
status: 500,
|
||||
message: 'Internal server error',
|
||||
code: 'internal_server_error',
|
||||
},
|
||||
},
|
||||
])('should handle $name through the error callbacks', async ({ payload }) => {
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const mockReader = {
|
||||
read: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n`),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: true,
|
||||
value: undefined,
|
||||
}),
|
||||
}
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onData).toHaveBeenCalledWith('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
errorMessage: payload.message,
|
||||
errorCode: payload.code,
|
||||
})
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith(true, payload.message)
|
||||
})
|
||||
|
||||
it('should handle malformed JSON gracefully', async () => {
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
+2
-1
@@ -328,7 +328,8 @@ export const handleStream = (
|
||||
onCompleted?.(true, 'Invalid response data')
|
||||
return
|
||||
}
|
||||
if (bufferObj.status === 400 || !bufferObj.event) {
|
||||
const hasErrorStatus = typeof bufferObj.status === 'number' && bufferObj.status >= 400
|
||||
if (bufferObj.event === 'error' || hasErrorStatus || !bufferObj.event) {
|
||||
onData('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
|
||||
Reference in New Issue
Block a user