chore: new agent enchance (#39040)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joel
2026-07-16 14:46:41 +08:00
committed by GitHub
parent 8ff92c9ef8
commit 1a2ba2237d
17 changed files with 541 additions and 73 deletions
+6 -6
View File
@@ -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)
@@ -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 () => {
@@ -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,
+150
View File
@@ -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)
})
})
+15 -2
View File
@@ -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()
+53
View File
@@ -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
View File
@@ -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: '',