Compare commits

...

201 Commits

Author SHA1 Message Date
yyh
03c58d151a test: strengthen model provider header coverage 2026-03-13 18:40:29 +08:00
yyh
fcc8e79733 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-13 18:19:14 +08:00
Stephen Zhou
4203647c32 chore: use vite plus (#33407) 2026-03-13 18:18:44 +08:00
dependabot[bot]
20e91990bf chore(deps): bump orjson from 3.11.4 to 3.11.6 in /api (#33380)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 17:33:33 +09:00
Asuka Minato
f38e8cca52 test: [Refactor/Chore] use Testcontainers to do sql test #32454 (#32460) 2026-03-13 17:32:39 +09:00
Coding On Star
00eda73ad1 test: enforce app/components coverage gates in web tests (#33395)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-03-13 16:31:05 +08:00
yyh
21cb5ae1b6 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-13 15:36:25 +08:00
Ethan T.
8b40a89add fix: with_debug_recipient() silently drops debug emails when user_id is None or empty string (#33373)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-03-13 15:35:02 +08:00
Joel
97776eabff chore: add tracking info of in site message (#33394)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-13 15:29:24 +08:00
yyh
a985be6282 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-13 15:11:39 +08:00
yyh
fe561ef3d0 feat(workflow): add edge context menu with delete support (#33391) 2026-03-13 15:11:24 +08:00
lif
1104d35bbb chore: remove unused WEAVIATE_GRPC_ENABLED config option (#33378)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-03-13 15:06:50 +08:00
Stephen Zhou
724eaee77e chore: add dev proxy server, update deps (#33371) 2026-03-13 12:52:19 +08:00
yyh
d0054e28fe Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-12 23:12:06 +08:00
Copilot
4717168fe2 chore(web): disable i18next support notice (#33309)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-03-12 18:18:10 +08:00
wangxiaolei
7fd3bd81ab fix: test_get_credentials_and_config_selects_plugin_id_and_key_api_ke… (#33361) 2026-03-12 19:09:46 +09:00
NFish
0dcfac5b84 fix: The HTTP Request node panel supports line break and overflow handling for variable values (#33359)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-12 17:57:08 +08:00
Stephen Zhou
b66097b5f3 chore: use tsconfigPaths for vinext (#33363) 2026-03-12 17:56:22 +08:00
CodingOnStar
a1410dc531 test: add helper text visibility tests for model selector popup
- Implemented tests to verify the display of the compatible-only helper text based on the presence of scope features.
- Updated the Popup component to conditionally render a banner when scope features are applied.
- Added localization for the new helper text in English, Japanese, and Simplified Chinese.
2026-03-12 16:53:04 +08:00
yyh
e2f433bab9 test(web): add coverage for workflow plugin install flows 2026-03-12 16:07:50 +08:00
CodingOnStar
64a66f2adc Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-12 15:51:42 +08:00
Coding On Star
ceaa399351 test: refactor mock implementation in markdown component tests (#33350)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-03-12 15:50:07 +08:00
yyh
e407d688d2 fix test 2026-03-12 15:45:24 +08:00
yyh
2f85c77a54 fix tests 2026-03-12 15:37:33 +08:00
Rajat Agarwal
dc50e4c4f2 test: added test cases for core.workflow module (#33126) 2026-03-12 15:35:25 +08:00
yyh
906852fbd6 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-12 15:34:35 +08:00
mahammadasim
157208ab1e test: added test for services of ops, summary, vector, website and ji… (#32893)
Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
2026-03-12 15:34:20 +08:00
mahammadasim
3dabdc8282 test: added tests for backend core.ops module (#32639)
Co-authored-by: rajatagarwal-oss <rajat.agarwal@infocusp.com>
2026-03-12 15:33:15 +08:00
yyh
729e18a7d6 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing
# Conflicts:
#	web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx
#	web/eslint-suppressions.json
2026-03-12 15:26:16 +08:00
Saumya Talwani
ed5511ce28 test: improve coverage for some files (#33218) 2026-03-12 15:09:10 +08:00
autofix-ci[bot]
4ed49f1d98 [autofix.ci] apply automated fixes 2026-03-12 07:03:15 +00:00
yyh
04f4627f9b refactor: extract providerSupportsCredits into shared utility
Unify the credits-support check across useCredentialPanelState and
model-selector popup by extracting a pure providerSupportsCredits
function. This also fixes the popup's credits-exhausted alert firing
for non-trial providers like Minimax and ZHIPU AI.
2026-03-12 15:00:29 +08:00
CodingOnStar
c167ee199c feat: implement dynamic plugin card icon URL generation
Added a utility function to generate plugin card icon URLs based on the plugin's source and workspace context. Updated the Card component to utilize this function for determining the correct icon source. Enhanced unit tests to verify the correct URL generation for both marketplace and package icons.
2026-03-12 14:58:16 +08:00
Saumya Talwani
68982f910e test: improve coverage parameters for some files in base (#33207) 2026-03-12 14:57:31 +08:00
yyh
339a8ca057 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-12 14:46:16 +08:00
yyh
d39f243a4a fix: check trial_models list before treating provider as credits-supported
Providers like Minimax and ZHIPU AI have system_configuration.enabled=true
(free hosting quota) but are not in the trial_models list, so they should
not display "AI credits in use" or the Usage Priority switcher.
2026-03-12 14:45:48 +08:00
yyh
c43307dae1 refactor(switch): Base UI migration with loading/skeleton variants (#33345)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-12 14:40:43 +08:00
CodingOnStar
911d52cafc fix: unify model status display across knowledge base and model triggers 2026-03-12 14:01:22 +08:00
Stephen Zhou
b44b37518a chore: update vinext (#33347) 2026-03-12 13:18:11 +08:00
yyh
fee6d13f44 fix: improve workspace edit modal UX 2026-03-12 11:56:16 +08:00
Rajat Agarwal
b170eabaf3 test: Unit test cases for core.tools module (#32447)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: wangxiaolei <fatelei@gmail.com>
Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
Co-authored-by: mahammadasim <135003320+mahammadasim@users.noreply.github.com>
2026-03-12 11:48:13 +08:00
mahammadasim
e99628b76f test: added test for core token buffer memory and model runtime (#32512)
Co-authored-by: rajatagarwal-oss <rajat.agarwal@infocusp.com>
2026-03-12 11:46:46 +08:00
mahammadasim
60fe5e7f00 test: added for core logging and core mcp (#32478)
Co-authored-by: rajatagarwal-oss <rajat.agarwal@infocusp.com>
2026-03-12 11:44:56 +08:00
mahammadasim
245f6b824d test: add test for core extension, external_data_tool and llm generator (#32468) 2026-03-12 11:44:38 +08:00
Dev Sharma
7d2054d4f4 test: add UTs for api/services recommend_app, tools, workflow (#32645)
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-12 11:37:03 +08:00
Rajat Agarwal
07e19c0748 test: unit test cases for core.variables, core.plugin, core.prompt module (#32637) 2026-03-12 11:29:02 +08:00
Dev Sharma
135b3a15a6 test: add UTs for api/ services.plugin (#32588)
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 11:27:29 +08:00
Rajat Agarwal
0045e387f5 test: unit test cases for core.app.apps module (#32482) 2026-03-12 11:23:25 +08:00
yyh
cb8e20786a fix: remove aria-hidden from version switch icon
The icon conveys interactive meaning (switch version), so it should
not be hidden from assistive technologies.
2026-03-12 11:15:34 +08:00
NFish
44713a5c0f fix: allow line breaks when a field value overflows due to excessive length (#33339) 2026-03-12 11:15:29 +08:00
CodingOnStar
d27a737cd1 test: add unit tests for RerankingModelSelector component 2026-03-12 11:12:25 +08:00
Rajat Agarwal
d5724aebde test: unit test cases core.agent module (#32474) 2026-03-12 11:10:15 +08:00
Rajat Agarwal
c59685748c test: unit test cases for core.callback, core.base, core.entities module (#32471) 2026-03-12 11:09:08 +08:00
Dev Sharma
36c1f4d506 test: improve unit tests for controllers.inner_api (#32203) 2026-03-12 11:07:56 +08:00
yyh
167fcc866d fix: use short "not configured" label for inline embedding model warning
Split embedding model validation message: checklist keeps full
"Embedding model not configured" while node inline row uses short
"Not configured" since the left label already says "Embedding model".
Also keep the row label color as tertiary gray instead of warning yellow.
2026-03-12 10:57:07 +08:00
CodingOnStar
82ad93eb1a Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-12 10:11:57 +08:00
yyh
f0086888e3 fix 2026-03-11 21:17:35 +08:00
yyh
ee2280851d fix: checklist popover 2026-03-11 21:07:33 +08:00
yyh
e9d0c7bb2a fix 2026-03-11 21:00:55 +08:00
yyh
06e1d59e1d chore: try 6 shard web tests ci 2026-03-11 20:53:17 +08:00
yyh
bd2bb27faa fix 2026-03-11 20:47:48 +08:00
yyh
c08b9a289b fix: tests 2026-03-11 20:42:40 +08:00
yyh
715a0fabfc fix: tests 2026-03-11 20:28:37 +08:00
yyh
5d07ccce59 fix: tests 2026-03-11 20:08:46 +08:00
autofix-ci[bot]
45e4d47207 [autofix.ci] apply automated fixes 2026-03-11 11:49:49 +00:00
yyh
fa664ebe77 refactor(web): migrate members settings overlays to base ui primitives 2026-03-11 19:39:05 +08:00
yyh
563d0c6892 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing
# Conflicts:
#	web/contract/router.ts
2026-03-11 19:02:56 +08:00
yyh
af2a6b2de0 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-11 18:19:41 +08:00
yyh
908e57b9f5 refactor: align Model Settings popover with Figma design
Restructure the popover layout to match design specs: add header with
close button, anchor popup to settings icon, change trigger to semantic
button, and widen panel to 400px.
2026-03-11 17:22:46 +08:00
yyh
d72fbce31c refactor: migrate PresetsParameter and ParameterItem to base/ui overlay primitives
Replace deprecated Dropdown, SimpleSelect, and Tooltip with DropdownMenu,
Select, and Tooltip compound components from base/ui. Hoist TONE_ICONS to
module level, remove FC in favor of function declarations, and prune
obsolete ESLint suppressions.
2026-03-11 16:54:14 +08:00
yyh
6cb68b6de5 fix: hide arrow-down chevron in trigger when status badge is shown 2026-03-11 16:46:18 +08:00
yyh
aeaf6d2ce9 fix: make model provider title sticky in selector dropdown
Add sticky positioning to provider title rows so they remain visible
while scrolling through models. Remove top padding from list container
to prevent the first provider title from shifting up before sticking.
2026-03-11 16:44:11 +08:00
yyh
ad4cb51983 refactor(trigger): derive multi-state status from credentials instead of collapsed disabled boolean
Replace the single `disabled` prop with a pure `deriveTriggerStatus` function
that maps to distinct states (empty, active, credits-exhausted, api-key-unavailable,
incompatible), each with its own badge text and tooltip. Unify non-workflow and
workflow modes into a single split layout, migrate icons to CSS icons, and add
per-status i18n tooltip keys.
2026-03-11 16:37:12 +08:00
CodingOnStar
3f27c8a9d2 fix(plugin-tasks): handle error actions by source and clear item after marketplace install 2026-03-11 15:59:37 +08:00
CodingOnStar
c2def7a840 fix: enhance model provider popup functionality and loading state handling
- Updated the model provider popup to include loading state for marketplace plugins.
- Improved filtering logic for installed models and marketplace providers.
- Added tests to ensure correct behavior when no models are found and when query parameters are omitted.
- Refactored the handling of model lists to better manage installed and available models.
2026-03-11 15:29:47 +08:00
CodingOnStar
f18fd566ba feat: implement model status mapping and enhance UI components
- Added a new status-mapping file to define internationalization keys for model statuses.
- Updated ModelName and Trigger components to conditionally display model metadata based on status.
- Enhanced tests for ModelSelectorTrigger to validate rendering behavior for different credential panel states.
- Improved styling and tooltip integration for status badges in the Trigger component.
2026-03-11 14:36:47 +08:00
yyh
0acc2eaa00 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-11 14:26:14 +08:00
yyh
e0947a1ea8 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing
# Conflicts:
#	web/eslint-suppressions.json
2026-03-11 14:23:04 +08:00
yyh
e51162af0c Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-11 11:57:11 +08:00
yyh
08da390678 fix: use destructive text color for api-unavailable credential name and remove redundant Unavailable label
The card-level StatusLabel now shows a red credential name for the
api-unavailable variant to match the Figma design. The "Unavailable"
text was removed since it only belongs inside the dropdown key list.
2026-03-11 11:56:50 +08:00
yyh
250450a54e fix: use primary button variant for api-required-add credential state
Align the "Add API Key" button to Figma design by switching from
secondary-accent to primary variant (blue bg + white text) for
providers with no AI credits and no API key configured.
2026-03-11 11:40:40 +08:00
CodingOnStar
5709a34a7f test: enhance ModelSelectorTrigger tests and integrate credential panel state
- Added tests for ModelSelectorTrigger to validate rendering based on credential panel state, including handling of credits exhausted scenarios.
- Updated ModelSelectorTrigger component to utilize useCredentialPanelState for determining status and rendering appropriate UI elements.
- Adjusted related tests to ensure correct behavior when model quota is exceeded and when the selected model is readonly.
- Improved styling for credits exhausted badge in the component.
2026-03-11 11:09:03 +08:00
CodingOnStar
e8ade9ad64 test(debug): add unit tests for Debug component and enhance Trigger component tests
- Introduced comprehensive unit tests for the Debug component, covering various states and interactions.
- Enhanced Trigger component tests to include new status badges, empty states, and improved rendering logic.
- Updated mock implementations to reflect changes in provider context and credential panel state.
- Ensured tests validate the correct rendering of UI elements based on different props and states.
2026-03-11 09:49:09 +08:00
CodingOnStar
34a5645d94 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-11 09:47:17 +08:00
yyh
5e80a3f5de fix: use css icons 2026-03-11 00:04:31 +08:00
yyh
785e04816e Revert "chore: refresh vinext lockfile"
This reverts commit 7699b0d430.
2026-03-10 23:50:22 +08:00
yyh
2704688f59 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-10 23:42:58 +08:00
yyh
7699b0d430 chore: refresh vinext lockfile 2026-03-10 23:42:56 +08:00
yyh
45c96dc254 feat(model-provider): add plugin update indicators and migrate to oRPC contracts
Problem: Model provider settings page (/plugins?action=showSettings&tab=provider)
was missing plugin update indicators (red dot badge, Update button) that the
/plugins page correctly displayed, because it only fetched installation data
without querying for latest marketplace versions.

Decision: Extract a shared usePluginsWithLatestVersion hook and migrate plugin
API endpoints to oRPC contracts, ensuring both pages use identical data flows.

Model: Both pages now follow the same pattern — fetch installed plugins via
consoleQuery.plugins.checkInstalled, enrich with latest version metadata via
usePluginsWithLatestVersion, then pass complete PluginDetail objects downstream
where useDetailHeaderState computes hasNewVersion for UI indicators.

Impact:
- Update badge red dot and Update button now appear on provider settings page
- Shared hook eliminates 15 lines of duplicate enrichment logic in plugins-panel
- oRPC contracts replace legacy post() calls for plugin endpoints
- Operation dropdown uses auto-width to prevent "View on Marketplace" text wrapping
- Version badge aligned to use Badge component consistently across both pages
- Update button tooltip added with bilingual i18n support
- Deprecated Tooltip migrated to Base UI Tooltip in detail-header
2026-03-10 23:28:09 +08:00
yyh
3a957cc28b Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-10 19:03:49 +08:00
CodingOnStar
7ed7562be6 feat(model-selector): add status badges and empty states for model trigger
- Add credits exhausted and API key unavailable split layout using useCredentialPanelState
  - Replace deprecated AlertTriangle icon with Incompatible badge and tooltip
  - Add empty state with brain icon placeholder and configure model text
  - Move STATUS_I18N_KEY to declarations.ts as shared constant
  - Redesign HasNotSetAPI as inline card layout, remove WarningMask overlay
  - Move no-API-key warning inline in debug panel, add no-model-selected state
  - Add i18n keys for en-US, ja-JP, zh-Hans
2026-03-10 18:02:14 +08:00
yyh
fda5d12107 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-10 17:41:31 +08:00
yyh
0b2ded3227 feat(knowledge-base): add fine-grained embedding model validation with inline warnings
Extract validation logic from default.ts into shared utils.ts, enabling
node card, panel, and checklist to share the same validation rules.
Introduce provider-scoped model list queries to detect non-active model
states (noConfigure, quotaExceeded, credentialRemoved, incompatible).
Expand node card from 2 rows to 4 rows with per-row warning indicators,
and add warningDot support to panel field titles.
2026-03-10 17:25:27 +08:00
yyh
369e4eb7b0 fix(model-selector): use native button elements for Base UI trigger components
Replace <div> with <button type="button"> in PopoverTrigger and
TooltipTrigger render props to satisfy Base UI's nativeButton
requirement and restore proper button semantics.
2026-03-10 16:41:16 +08:00
yyh
a4942139d2 chore(model-selector): remove redundant z-index hacks after overlay unification
Now that base/ui primitives carry z-[1002] by default (#33185),
the per-call-site overrides (z-[1002] on ModelSelector, z-[1003]
on nested PopupItem dropdown) are no longer needed — DOM order
handles stacking for same-z-index portals.
2026-03-10 14:05:09 +08:00
yyh
83c15227f6 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-10 13:58:19 +08:00
yyh
60f86f0520 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-10 11:43:09 +08:00
yyh
b3c98e417d Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-09 23:47:27 +08:00
yyh
dfe389c017 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-09 23:42:04 +08:00
yyh
b364b06e51 refactor(model-selector): migrate overlays to Popover/Tooltip and unify trigger component
- Migrate PortalToFollowElem to base-ui Popover in model-selector,
  model-parameter-modal, and plugin-detail-panel model-selector
- Migrate legacy Tooltip to compound Tooltip in popup-item and trigger
- Unify EmptyTrigger, ModelTrigger, DeprecatedModelTrigger into a
  single declarative ModelSelectorTrigger that derives state from props
- Remove showDeprecatedWarnIcon boolean prop anti-pattern; deprecated
  state always renders warn icon as part of component's visual contract
- Remove deprecatedClassName prop; component manages disabled styling
- Replace manual triggerRef width measurement with CSS var(--anchor-width)
- Remove tooltip scroll listener (base-ui auto-tracks anchor position)
- Restore conditional placement for workflow mode in plugin-detail-panel
- Prune stale ESLint suppressions for removed deprecated imports
2026-03-09 23:34:42 +08:00
CodingOnStar
ce0197b107 fix(provider): handle undefined provider in credential status and panel state 2026-03-09 18:20:02 +08:00
yyh
164cefc65c Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-09 17:41:13 +08:00
yyh
f6d80b9fa7 fix(workflow): derive plugin install state in render
Remove useEffect-based sync of _pluginInstallLocked/_dimmed in workflow nodes to avoid render-update loops.\n\nMove plugin-missing checks to pure utilities and use them in checklist.\nOptimize node installation hooks by enabling only relevant queries and narrowing memo dependencies.
2026-03-09 17:18:09 +08:00
yyh
e845fa7e6a fix(plugin-install): support bundle marketplace dependency shape 2026-03-09 17:07:27 +08:00
yyh
bab7bd5ecc Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-09 17:03:54 +08:00
yyh
cfb02bceaf feat(workflow): open install bundle from checklist and strict marketplace parsing 2026-03-09 17:03:43 +08:00
yyh
694ca840e1 feat(web): add warning dot indicator on LLM panel field labels synced with checklist
Store checklist items in zustand WorkflowStore so both the checklist UI
and node panels share a single source of truth. The LLM panel reads from
the store to show a Figma-aligned warning dot (absolute-positioned, no
layout shift) on the MODEL field label when the node has checklist warnings.
2026-03-09 16:38:31 +08:00
yyh
2d979e2cec fix(web): silence toast for model parameter rules fetch on missing provider
Add silent option to useModelParameterRules API call so uninstalled
provider errors are swallowed instead of surfacing a raw backend toast.
2026-03-09 16:17:09 +08:00
yyh
5cee7cf8ce feat(web): add LLM model plugin check to workflow checklist
Detect uninstalled model plugins for LLM nodes in the checklist and
publish-gate. Migrate ChecklistItem.errorMessage to errorMessages[]
so a single node can surface multiple validation issues at once.

- Extract shared extractPluginId utility for checklist and prompt editor
- Build installed-plugin Set (O(1) lookup) from ProviderContext
- Remove short-circuit between checkValid and variable validation
- Sync the same check into handleCheckBeforePublish
- Adapt node-group, use-last-run, and test assertions
2026-03-09 16:16:16 +08:00
yyh
0c17823c8b fix 2026-03-09 15:38:46 +08:00
yyh
49c6696d08 fix: use css icons 2026-03-09 15:27:08 +08:00
yyh
292c98a8f3 refactor(web): redesign workflow checklist panel with grouped tree view and Popover primitive
Migrate checklist from flat card list using deprecated PortalToFollowElem to
grouped tree view using base-ui Popover. Split into checklist/ directory with
separate components: plugin group with batch install, per-node groups with
sub-items and "Go to fix" hover action, and tree-line SVG indicators.
2026-03-09 15:23:34 +08:00
CodingOnStar
0e0a6ad043 test(web): enhance unit tests for credential and popup components
- Updated tests for CredentialItem to improve delete button interaction and check icon rendering.
- Enhanced PopupItem tests by mocking credential panel state for various scenarios, ensuring accurate rendering based on credit status.
- Adjusted Popup tests to include trial credits mock for better coverage of credit management logic.
- Refactored model list item tests to include wrapper for consistent rendering context.
2026-03-09 14:20:12 +08:00
yyh
456c95adb1 refactor(web): trigger error tooltip on entire variable badge hover 2026-03-09 14:03:52 +08:00
yyh
1abbaf9fd5 feat(web): differentiate invalid variable tooltips by model plugin status
Replace the generic "Invalid variable" message in prompt editor variable
labels with two context-aware messages: one for missing nodes and another
for uninstalled model plugins. Add useLlmModelPluginInstalled hook that
checks LLM node model providers against installed providers via
useProviderContextSelector. Migrate Tooltip usage to base-ui primitives
and replace RiErrorWarningFill with Warning icon in warning color.
2026-03-09 14:02:26 +08:00
CodingOnStar
1a26e1669b refactor(web): streamline PopupItem component for credit management
- Removed unused context and variables related to workspace and custom configuration.
- Simplified credit usage logic by leveraging state management for better clarity and performance.
- Enhanced readability by restructuring the code for determining credit status and API key activity.
2026-03-09 13:10:29 +08:00
CodingOnStar
02444af2e3 feat(web): enhance Popup and CreditsFallbackAlert components for better credit management
- Integrated trial credits check in the Popup component to conditionally display the CreditsExhaustedAlert.
- Updated the CreditsFallbackAlert to show a message only when API keys are unavailable.
- Removed the fallback description from translation files as it is no longer used.
2026-03-09 12:57:41 +08:00
CodingOnStar
56038e3684 feat(web): update credits fallback alert to include new description for no API keys
- Modified the CreditsFallbackAlert component to display a different message based on the presence of API keys.
- Added a new translation key for the fallback description in both English and Chinese JSON files.
2026-03-09 12:34:41 +08:00
CodingOnStar
eb9341e7ec feat(web): integrate CreditsCoin icon in PopupItem for enhanced UI
- Replaced the existing credits coin span with the CreditsCoin component for improved visual consistency.
- Updated imports to include the new CreditsCoin icon component.
2026-03-09 12:28:13 +08:00
CodingOnStar
e40b31b9c4 refactor(web): enhance model selector functionality and improve UI consistency
- Removed unnecessary ESLint suppressions for better code quality.
- Updated the ModelParameterModal and ModelSelector components to ensure consistent class ordering.
- Added onHide prop to ModelSelector for better control over dropdown visibility.
- Introduced useChangeProviderPriority hook to manage provider priority changes more effectively.
- Integrated CreditsExhaustedAlert in the Popup component to handle API key status more gracefully.
2026-03-09 12:24:54 +08:00
yyh
b89ee4807f Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing
# Conflicts:
#	web/app/components/header/account-setting/model-provider-page/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/system-model-selector/index.spec.tsx
2026-03-09 12:12:27 +08:00
yyh
9907cf9e06 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-08 22:27:42 +08:00
yyh
208a31719f Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-08 01:10:51 +08:00
yyh
3d1ef1f7f5 Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-06 21:45:37 +08:00
CodingOnStar
24b14e2c1a Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing 2026-03-06 19:00:17 +08:00
CodingOnStar
53f122f717 Merge branch 'feat/model-provider-refactor' into feat/model-plugins-implementing 2026-03-06 17:33:38 +08:00
CodingOnStar
fced2f9e65 refactor: enhance plugin management UI with error handling, improved rendering, and new components 2026-03-06 16:27:26 +08:00
yyh
0c08c4016d Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor 2026-03-06 14:57:48 +08:00
CodingOnStar
ff4e4a8d64 refactor: enhance model trigger component with internationalization support and improved tooltip handling 2026-03-06 14:50:23 +08:00
yyh
948efa129f Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor 2026-03-06 14:47:56 +08:00
CodingOnStar
e371bfd676 refactor: enhance model provider management with new icons, improved UI elements, and marketplace integration 2026-03-06 14:18:29 +08:00
yyh
6d612c0909 test: improve Jotai atom test quality and add model-provider atoms tests
Replace dynamic imports with static imports in marketplace atom tests.
Convert type-only and not-toThrow assertions into proper state-change
verifications. Add comprehensive test suite for model-provider-page
atoms covering all four hooks, cross-hook interaction, selectAtom
granularity, and Provider isolation.
2026-03-05 22:49:09 +08:00
yyh
56e0dc0ae6 trigger ci
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
2026-03-05 21:22:03 +08:00
yyh
975eca00c3 Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor 2026-03-05 20:25:53 +08:00
yyh
f049bafcc3 refactor: simplify Jotai atoms by removing redundant write-only atoms
Replace 2 write-only derived atoms with primitive atom's built-in
updater functions. The selectAtom on the read side already prevents
unnecessary re-renders, making the manual guard logic redundant.
2026-03-05 20:25:29 +08:00
CodingOnStar
dd9c526447 refactor: update model-selector popup-item to support collapsible items and improve icon color handling 2026-03-05 16:45:37 +08:00
yyh
922dc71e36 fix 2026-03-05 16:17:38 +08:00
yyh
f03ec7f671 Merge branch 'main' into feat/model-provider-refactor 2026-03-05 16:14:36 +08:00
yyh
29f275442d Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor
# Conflicts:
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx
#	web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx
2026-03-05 16:13:40 +08:00
yyh
c9532ffd43 add stories 2026-03-05 15:55:21 +08:00
yyh
840dc33b8b Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor 2026-03-05 15:12:32 +08:00
yyh
cae58a0649 Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor 2026-03-05 15:08:13 +08:00
yyh
1752edc047 refactor(web): optimize model provider re-render and remove useEffect state sync
- Replace useEffect state sync with derived state pattern in useSystemDefaultModelAndModelList
- Use useCallback instead of useMemo for function memoization in useProviderCredentialsAndLoadBalancing
- Add memo() to ProviderAddedCard and CredentialPanel to prevent unnecessary re-renders
- Switch to useProviderContextSelector for precise context subscription in ProviderAddedCard
- Stabilize activate callback ref in useActivateCredential via supportedModelTypes ref
- Add usage priority tooltip with i18n support
2026-03-05 15:07:53 +08:00
yyh
7471c32612 Revert "temp: remove IS_CLOUD_EDITION guard from supportsCredits for local testing"
This reverts commit ab87ac333a.
2026-03-05 14:33:48 +08:00
yyh
2d333bbbe5 refactor(web): extract credential activation into hook and migrate credential-item overlays
Extract credential switching logic from dropdown-content into a dedicated
useActivateCredential hook with optimistic updates and proper data flow
separation. Credential items now stay visible in the popover after clicking
(no auto-close), show cursor-pointer, and disable during activation.

Migrate credential-item from legacy Tooltip and remixicon imports to
base-ui Tooltip and CSS icon classes, pruning stale ESLint suppressions.
2026-03-05 14:22:39 +08:00
yyh
4af6788ce0 fix(web): wrap Header test in Dialog context for base-ui compatibility 2026-03-05 14:20:35 +08:00
yyh
24b072def9 fix: lint 2026-03-05 14:08:20 +08:00
yyh
909c8c3350 Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor 2026-03-05 13:58:51 +08:00
yyh
80e9c8bee0 refactor(web): make account setting fully controlled with action props 2026-03-05 13:39:36 +08:00
yyh
15b7b304d2 refactor(web): migrate model-modal overlays to base-ui Dialog and AlertDialog
Replace legacy PortalToFollowElem and Confirm with Dialog/AlertDialog
primitives. Remove manual ESC handler and backdrop div — now handled
natively by base-ui. Add backdropProps={{ forceRender: true }} for
correct nested overlay rendering.
2026-03-05 13:33:53 +08:00
yyh
61e2672b59 refactor(web): make provider reset event-driven and scope model invalidation
- remove provider-page lifecycle reset effect and handle reset in explicit tab/close actions
- switch account setting tab state to controlled/uncontrolled pattern without sync effect
- use provider-scoped model list queryKey with exact invalidation in credential and model toggle mutations
- update related tests and mocks for new behavior
2026-03-05 13:28:30 +08:00
yyh
5f4ed4c6f6 refactor(web): replace model provider emitter refresh with jotai state
- add atom-based provider expansion state with reset/prune helpers
- remove event-emitter dependency from model provider refresh flow
- invalidate exact provider model-list query key on refresh
- reset expansion state on model provider page mount/unmount
- update and extend tests for external expansion and query invalidation
- update eslint suppressions to match current code
2026-03-05 13:20:58 +08:00
yyh
4a1032c628 fix(web): remove redundant hover text swap on show models button
Merge the two hover-toggling divs into a single always-visible element
and remove the unused showModelsNum i18n key from all locales.
2026-03-05 13:16:04 +08:00
yyh
423c97a47e code style 2026-03-05 13:09:33 +08:00
yyh
a7e3fb2e33 fix(web): use triangle Warning icon instead of circle error icon
Replace i-ri-error-warning-fill (circle exclamation) with the
Warning component (triangle) for api-fallback and credits-fallback
variants to match Figma design.
2026-03-05 13:07:20 +08:00
yyh
ce34937a1c feat(web): add credits-fallback variant for API Key priority with available credits
When API Key is selected but unavailable/unconfigured and credits are
available, the card now shows "AI credits in use" with a warning icon
instead of "API key required". When both credits are exhausted and no
API key exists, it shows "No available usage" (destructive).

New deriveVariant logic for priority=apiKey:
- !exhausted + !authorized → credits-fallback (was api-required-*)
- exhausted + no credential → no-usage (was api-required-add)
- exhausted + named unauthorized → api-unavailable (unchanged)
2026-03-05 13:02:40 +08:00
yyh
ad9ac6978e fix(web): align alert card width with API key section in dropdown
Change mx-1 (4px) to mx-2 (8px) on CreditsFallbackAlert and
CreditsExhaustedAlert to match ApiKeySection's p-2 (8px) padding,
consistent with Figma design where both sections are 8px from the
dropdown edge.
2026-03-05 12:56:55 +08:00
yyh
57c1ba3543 fix(web): hide divider above empty API keys state in dropdown
Move the border from UsagePrioritySection (always visible) to
ApiKeySection's list variant (only when credentials exist). This
removes the unwanted divider line above the "No API Keys" empty
state card when on the AI Credits tab with no keys configured.
2026-03-05 12:25:11 +08:00
yyh
d7a5af2b9a Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor
# Conflicts:
#	web/app/components/header/account-setting/model-provider-page/index.tsx
2026-03-05 10:46:24 +08:00
yyh
d45edffaa3 fix(web): wire upgrade link to pricing modal and add credits-coin icon
Replace broken HTML string interpolation with Trans component and
useModalContextSelector so "upgrade your plan" opens the pricing modal.
Add custom credits-coin SVG icon to replace the generic ri-coin-line.
2026-03-05 10:39:31 +08:00
yyh
530515b6ef fix(web): prevent model list from expanding on priority switch
Remove UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST event emission from
changePriority onSuccess. This event was designed for custom model
add/edit/delete scenarios where the card should expand, but firing
it on priority switch caused ProviderAddedCard to unexpectedly
expand via refreshModelList → setCollapsed(false).
2026-03-05 10:35:03 +08:00
yyh
f13f0d1f9a fix(web): align dropdown alerts with Figma design and fix hardcoded credits total
- Expose totalCredits from useTrialCredits hook instead of hardcoding 10,000
- Align CreditsExhaustedAlert with Figma: dynamic progress bar, correct
  design tokens (components-progress-error-bg/progress), sm-medium/xs-regular
  typography
- Align CreditsFallbackAlert typography to sm-medium/xs-regular
- Fix ApiKeySection empty state: horizontal gradient, sm-medium title,
  Figma-aligned padding (pl-7 for API KEYS label)
- Hoist empty credentials array constant to stabilize memo (rerender-memo-with-default-value)
- Remove redundant useCallback wrapper in ApiKeySection
- Replace nested ternary with Record lookup in TextLabel
- Remove dead || 0 guard in useTrialCredits
- Update all test mocks with totalCredits field
2026-03-05 10:09:51 +08:00
yyh
b597d52c11 refactor(web): remove dialog description from system model selector
Remove the DialogDescription and its i18n key (modelProvider.systemModelSettingsLink)
from the system model settings dialog across all 23 locales.
2026-03-05 10:05:01 +08:00
yyh
34c42fe666 Revert "temp: remove cloud condition"
This reverts commit 29e344ac8b.
2026-03-05 09:44:19 +08:00
yyh
dc109c99f0 test(web): expand credential panel and dropdown test coverage for all 8 card variants
Add comprehensive behavioral tests covering all discriminated union variants,
destructive/default styling, warning icons, CreditsFallbackAlert conditions,
credential CRUD interactions, AlertDialog delete confirmation, and Popover behavior.
2026-03-05 09:41:48 +08:00
yyh
223b9d89c1 refactor(web): migrate priority change to oRPC contract with useMutation
- Add changePreferredProviderType contract in model-providers.ts
- Register in consoleRouterContract
- Replace raw async changeModelProviderPriority with useMutation
- Use Toast.notify (static API) instead of useToastContext hook
- Pass isPending as isChangingPriority to disable buttons during switch
- Add disabled prop to UsagePrioritySection
- Fix pre-existing test assertions for api-unavailable variant
- Update all specs with isChangingPriority prop and oRPC mock pattern
2026-03-05 09:30:38 +08:00
yyh
dd119eb44f fix(web): align UsagePrioritySection with Figma design and fix i18n key ordering
- Single-row layout for icon, label, and option cards
- Icon: arrow-up-double-line matching design spec
- Buttons: flexible width with whitespace-nowrap instead of fixed w-[72px]
- Add min-w-0 + truncate for text overflow, focus-visible ring for a11y
- Sort modelProvider.card.* i18n keys alphabetically
2026-03-05 09:15:16 +08:00
yyh
970493fa85 test(web): update tests for credential panel refactoring and new ModelAuthDropdown components
Rewrite credential-panel.spec.tsx to match the new discriminated union
state model and variant-driven rendering. Add new test files for
useCredentialPanelState hook, SystemQuotaCard Label enhancement,
and all ModelAuthDropdown sub-components.
2026-03-05 08:41:17 +08:00
yyh
ab87ac333a temp: remove IS_CLOUD_EDITION guard from supportsCredits for local testing 2026-03-05 08:34:10 +08:00
yyh
b8b70da9ad refactor(web): rewrite CredentialPanel with declarative variant-driven state and new ModelAuthDropdown
- Extract useCredentialPanelState hook with discriminated union CardVariant type replacing scattered boolean conditions
- Create ModelAuthDropdown compound component (Popover-based) with UsagePrioritySection, CreditsExhaustedAlert, and ApiKeySection
- Enhance SystemQuotaCard.Label to accept className override for flexible styling
- Add i18n keys for new card states and dropdown content (en-US, zh-Hans)
2026-03-05 08:33:04 +08:00
yyh
77d81aebe8 Merge remote-tracking branch 'origin/main' into feat/model-provider-refactor 2026-03-04 23:35:20 +08:00
yyh
deb4cd3ece fix: i18n 2026-03-04 23:35:13 +08:00
yyh
648d9ef1f9 refactor(web): extract SystemQuotaCard compound component and shared useTrialCredits hook
Extract trial credits calculation into a shared useTrialCredits hook to prevent
logic drift between QuotaPanel and CredentialPanel. Add SystemQuotaCard compound
component with explicit default/destructive variants for the system quota UI
state in provider cards, replacing inline conditional styling with composable
Label and Actions slots. Remove unnecessary useMemo for simple derived values.
2026-03-04 23:30:25 +08:00
yyh
5ed4797078 fix 2026-03-04 22:53:29 +08:00
yyh
62631658e9 fix(web): update tests for AlertDialog migration and component API changes
- Replace deprecated Confirm mock with real AlertDialog role-based queries
- Add useInvalidateCheckInstalled mock for QueryClient dependency
- Wrap model-list-item renders in QueryClientProvider
- Migrate PluginVersionPicker from PortalToFollowElem to Popover
- Migrate UpdatePluginModal from Modal to Dialog
- Update version picker offset props (sideOffset/alignOffset)
2026-03-04 22:52:21 +08:00
yyh
22a4100dd7 fix(web): invalidate plugin checkInstalled cache after version updates 2026-03-04 22:33:17 +08:00
yyh
0f7ed6f67e refactor(web): align provider badges with figma and remove dead add-model-button 2026-03-04 22:29:51 +08:00
yyh
4d9fcbec57 refactor(web): migrate remove-plugin dialog to base UI AlertDialog and improve UX
- Replace deprecated Confirm component with AlertDialog primitives
- Add forceRender backdrop for proper overlay rendering
- Add success Toast notification after plugin removal
- Update "View Detail" text to "View on Marketplace" (en/zh-Hans)
- Add i18n keys for delete success message
- Prune stale eslint suppression for header-modals
2026-03-04 22:14:19 +08:00
yyh
4d7a9bc798 fix(web): align model provider cache invalidation with oRPC keys 2026-03-04 22:06:27 +08:00
yyh
d6d04ed657 fix 2026-03-04 22:03:06 +08:00
yyh
f594a71dae fix: icon 2026-03-04 22:02:36 +08:00
yyh
04e0ab7eda refactor(web): migrate provider-added-card model list to oRPC query-driven state 2026-03-04 21:55:34 +08:00
yyh
784bda9c86 refactor(web): migrate operation-dropdown to base UI and align provider card styles with Figma
- Migrate OperationDropdown from legacy portal-to-follow-elem to base UI DropdownMenu primitives
- Add placement, sideOffset, alignOffset, popupClassName props for flexible positioning
- Fix version badge font size: system-2xs-medium-uppercase (10px) → system-xs-medium-uppercase (12px)
- Set provider card dropdown to bottom-start placement with 192px width per Figma spec
- Fix PluginVersionPicker toggle: clicking badge now opens and closes the picker
- Add max-h-[224px] overflow scroll to version list
- Replace Remix icon imports with Tailwind CSS icon classes
- Prune stale eslint suppressions for migrated files
2026-03-04 21:55:23 +08:00
yyh
1af1fb6913 feat(web): add version badge and actions menu to provider cards
Integrate plugin version management into model provider cards by
reusing existing plugin detail panel hooks and components. Batch
query installed plugins at list level to avoid N+1 requests.
2026-03-04 21:29:52 +08:00
yyh
1f0c36e9f7 fix: style 2026-03-04 21:07:42 +08:00
yyh
455ae65025 fix: style 2026-03-04 20:58:14 +08:00
yyh
d44682e957 refactor(web): align quota panel with Figma design and migrate to base UI tooltip
- Rename title from "Quota" to "AI Credits" and update tooltip copy
  (Message Credits → AI Credits, free → Trial)
- Show "Credits exhausted" in destructive text when credits reach zero
  instead of displaying the number "0"
- Migrate from deprecated Tooltip to base UI Tooltip compound component
- Add 4px grid background with radial fade mask via CSS module
- Simplify provider icon tooltip text for uninstalled state
- Update i18n keys for both en-US and zh-Hans
2026-03-04 20:52:30 +08:00
yyh
8c4afc0c18 fix(model-selector): align empty trigger with default trigger style 2026-03-04 20:14:49 +08:00
yyh
539cbcae6a fix(account-settings): render nested system model backdrop via base ui 2026-03-04 19:57:53 +08:00
yyh
8d257fea7c chore(web): commit dialog overlay follow-up changes 2026-03-04 19:37:10 +08:00
yyh
c3364ac350 refactor(web): align account settings dialogs with base UI 2026-03-04 19:31:14 +08:00
yyh
f991644989 refactor(pricing): migrate to base ui dialog and extract category types 2026-03-04 19:26:54 +08:00
yyh
29e344ac8b temp: remove cloud condition 2026-03-04 18:50:38 +08:00
yyh
1ad9305732 fix(web): avoid quota panel flicker on account-setting tab switch
- remove mount-time workspace invalidate in model provider page

- read quota with useCurrentWorkspace and keep loading only for initial empty fetch

- reuse existing useSystemFeaturesQuery for marketplace and trial models

- update model provider and quota panel tests for new query/loading behavior
2026-03-04 18:43:01 +08:00
yyh
17f38f171d lint 2026-03-04 18:21:59 +08:00
yyh
802088c8eb test(web): fix trivial assertion and add useInvalidateDefaultModel tests
Replace the no-provider test assertion from checking a nonexistent i18n
key to verifying actual warning keys are absent. Add unit tests for
useInvalidateDefaultModel following the useUpdateModelList pattern.
2026-03-04 17:51:20 +08:00
yyh
cad6d94491 refactor(web): replace remixicon imports with Tailwind CSS icons in system-model-selector 2026-03-04 17:45:41 +08:00
yyh
621d0fb2c9 fix 2026-03-04 17:42:34 +08:00
yyh
a92fb3244b fix(web): skip top warning for no-provider state and remove unused i18n key
The empty state card below already prompts users to install a provider,
so the top warning bar is redundant for the no-provider case. Remove
the unused noProviderInstalled i18n key and replace the lookup map with
a ternary to preserve i18n literal types without assertions.
2026-03-04 17:39:49 +08:00
yyh
97508f8d7b fix(web): invalidate default model cache after saving system model settings
After saving system models, only the model list cache was invalidated
but not the default model cache, causing stale config status in the UI.
Add useInvalidateDefaultModel hook and call it for all 5 model types
after a successful save.
2026-03-04 17:26:24 +08:00
yyh
70e677a6ac feat(web): refine system model settings to 4 distinct config states
Replace the single `defaultModelNotConfigured` boolean with a derived
`systemModelConfigStatus` that distinguishes between no-provider,
none-configured, partially-configured, and fully-configured states,
each showing a context-appropriate warning message. Also updates the
button label from "System Model Settings" to "Default Model Settings"
and migrates remixicon imports to Tailwind CSS icon classes.
2026-03-04 16:58:46 +08:00
710 changed files with 81734 additions and 8696 deletions

View File

@@ -1,33 +1,13 @@
name: Setup Web Environment
description: Setup pnpm, Node.js, and install web dependencies.
inputs:
node-version:
description: Node.js version to use
required: false
default: "22"
install-dependencies:
description: Whether to install web dependencies after setting up Node.js
required: false
default: "true"
runs:
using: composite
steps:
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Vite+
uses: voidzero-dev/setup-vp@b5d848f5a62488f3d3d920f8aa6ac318a60c5f07 # v1
with:
package_json_file: web/package.json
run_install: false
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Install dependencies
if: ${{ inputs.install-dependencies == 'true' }}
shell: bash
run: pnpm --dir web install --frozen-lockfile
node-version-file: "./web/.nvmrc"
cache: true
run-install: |
- cwd: ./web
args: ['--frozen-lockfile']

View File

@@ -102,13 +102,11 @@ jobs:
- name: Setup web environment
if: steps.web-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
with:
node-version: "24"
- name: ESLint autofix
if: steps.web-changes.outputs.any_changed == 'true'
run: |
cd web
pnpm eslint --concurrency=2 --prune-suppressions --quiet || true
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3

View File

@@ -62,6 +62,9 @@ jobs:
needs: check-changes
if: needs.check-changes.outputs.web-changed == 'true'
uses: ./.github/workflows/web-tests.yml
with:
base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
style-check:
name: Style Check

View File

@@ -88,7 +88,7 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: |
pnpm run lint:ci
vp run lint:ci
# pnpm run lint:report
# continue-on-error: true
@@ -102,17 +102,17 @@ jobs:
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run lint:tss
run: vp run lint:tss
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run type-check
run: vp run type-check
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run knip
run: vp run knip
superlinter:
name: SuperLinter

View File

@@ -50,8 +50,6 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
with:
install-dependencies: "false"
- name: Detect changed files and generate diff
id: detect_changes

View File

@@ -2,6 +2,13 @@ name: Web Tests
on:
workflow_call:
inputs:
base_sha:
required: false
type: string
head_sha:
required: false
type: string
permissions:
contents: read
@@ -14,11 +21,13 @@ jobs:
test:
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: ubuntu-latest
env:
VITEST_COVERAGE_SCOPE: app-components
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
shardIndex: [1, 2, 3, 4, 5, 6]
shardTotal: [6]
defaults:
run:
shell: bash
@@ -34,7 +43,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Run tests
run: pnpm vitest run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
- name: Upload blob report
if: ${{ !cancelled() }}
@@ -50,6 +59,8 @@ jobs:
if: ${{ !cancelled() }}
needs: [test]
runs-on: ubuntu-latest
env:
VITEST_COVERAGE_SCOPE: app-components
defaults:
run:
shell: bash
@@ -59,6 +70,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup web environment
@@ -72,7 +84,13 @@ jobs:
merge-multiple: true
- name: Merge reports
run: pnpm vitest --merge-reports --coverage --silent=passed-only
run: vp test --merge-reports --reporter=json --reporter=agent --coverage
- name: Check app/components diff coverage
env:
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
run: node ./scripts/check-components-diff-coverage.mjs
- name: Coverage Summary
if: always()
@@ -429,4 +447,4 @@ jobs:
- name: Web build check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run build
run: vp run build

View File

@@ -188,7 +188,6 @@ VECTOR_INDEX_NAME_PREFIX=Vector_index
# Weaviate configuration
WEAVIATE_ENDPOINT=http://localhost:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENABLED=false
WEAVIATE_BATCH_SIZE=100
WEAVIATE_TOKENIZATION=word

View File

@@ -17,11 +17,6 @@ class WeaviateConfig(BaseSettings):
default=None,
)
WEAVIATE_GRPC_ENABLED: bool = Field(
description="Whether to enable gRPC for Weaviate connection (True for gRPC, False for HTTP)",
default=True,
)
WEAVIATE_GRPC_ENDPOINT: str | None = Field(
description="URL of the Weaviate gRPC server (e.g., 'grpc://localhost:50051' or 'grpcs://weaviate.example.com:443')",
default=None,

View File

@@ -114,6 +114,7 @@ def get_user_tenant(view_func: Callable[P, R]):
def plugin_data(view: Callable[P, R] | None = None, *, payload_type: type[BaseModel]):
def decorator(view_func: Callable[P, R]):
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs):
try:
data = request.get_json()

View File

@@ -114,7 +114,7 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter):
metadata = sub_stream_response_dict.get("metadata", {})
sub_stream_response_dict["metadata"] = cls._get_simple_metadata(metadata)
response_chunk.update(sub_stream_response_dict)
if isinstance(sub_stream_response, ErrorStreamResponse):
elif isinstance(sub_stream_response, ErrorStreamResponse):
data = cls._error_to_stream_response(sub_stream_response.err)
response_chunk.update(data)
elif isinstance(sub_stream_response, NodeStartStreamResponse | NodeFinishStreamResponse):

View File

@@ -113,7 +113,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter):
metadata = sub_stream_response_dict.get("metadata", {})
sub_stream_response_dict["metadata"] = cls._get_simple_metadata(metadata)
response_chunk.update(sub_stream_response_dict)
if isinstance(sub_stream_response, ErrorStreamResponse):
elif isinstance(sub_stream_response, ErrorStreamResponse):
data = cls._error_to_stream_response(sub_stream_response.err)
response_chunk.update(data)
else:

View File

@@ -113,7 +113,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter):
metadata = sub_stream_response_dict.get("metadata", {})
sub_stream_response_dict["metadata"] = cls._get_simple_metadata(metadata)
response_chunk.update(sub_stream_response_dict)
if isinstance(sub_stream_response, ErrorStreamResponse):
elif isinstance(sub_stream_response, ErrorStreamResponse):
data = cls._error_to_stream_response(sub_stream_response.err)
response_chunk.update(data)
else:

View File

@@ -193,7 +193,8 @@ class LLMGenerator:
error_step = "generate rule config"
except Exception as e:
logger.exception("Failed to generate rule config, model: %s", args.model_config_data.name)
rule_config["error"] = str(e)
error = str(e)
error_step = "generate rule config"
rule_config["error"] = f"Failed to {error_step}. Error: {error}" if error else ""
@@ -279,7 +280,8 @@ class LLMGenerator:
except Exception as e:
logger.exception("Failed to generate rule config, model: %s", args.model_config_data.name)
rule_config["error"] = str(e)
error = str(e)
error_step = "handle unexpected exception"
rule_config["error"] = f"Failed to {error_step}. Error: {error}" if error else ""

View File

@@ -191,7 +191,7 @@ def cast_parameter_value(typ: StrEnum, value: Any, /):
except ValueError:
raise
except Exception:
raise ValueError(f"The tool parameter value {value} is not in correct type of {as_normal_type(typ)}.")
raise ValueError(f"The tool parameter value {repr(value)} is not in correct type of {as_normal_type(typ)}.")
def init_frontend_parameter(rule: PluginParameter, type: StrEnum, value: Any):

View File

@@ -113,17 +113,26 @@ class BuiltinToolProviderController(ToolProviderController):
"""
return self.get_credentials_schema_by_type(CredentialType.API_KEY)
def get_credentials_schema_by_type(self, credential_type: str) -> list[ProviderConfig]:
def get_credentials_schema_by_type(self, credential_type: CredentialType | str) -> list[ProviderConfig]:
"""
returns the credentials schema of the provider
:param credential_type: the type of the credential
:return: the credentials schema of the provider
:param credential_type: the type of the credential, as CredentialType or str; str values
are normalized via CredentialType.of and may raise ValueError for invalid values.
:return: list[ProviderConfig] for CredentialType.OAUTH2 or CredentialType.API_KEY, an
empty list for CredentialType.UNAUTHORIZED or missing schemas.
Reads from self.entity.oauth_schema and self.entity.credentials_schema.
Raises ValueError for invalid credential types.
"""
if credential_type == CredentialType.OAUTH2.value:
if isinstance(credential_type, str):
credential_type = CredentialType.of(credential_type)
if credential_type == CredentialType.OAUTH2:
return self.entity.oauth_schema.credentials_schema.copy() if self.entity.oauth_schema else []
if credential_type == CredentialType.API_KEY:
return self.entity.credentials_schema.copy() if self.entity.credentials_schema else []
if credential_type == CredentialType.UNAUTHORIZED:
return []
raise ValueError(f"Invalid credential type: {credential_type}")
def get_oauth_client_schema(self) -> list[ProviderConfig]:

View File

@@ -137,6 +137,7 @@ class ToolFileManager:
session.add(tool_file)
session.commit()
session.refresh(tool_file)
return tool_file

View File

@@ -276,7 +276,4 @@ class ToolPromptMessage(PromptMessage):
:return: True if prompt message is empty, False otherwise
"""
if not super().is_empty() and not self.tool_call_id:
return False
return True
return super().is_empty() and not self.tool_call_id

View File

@@ -4,7 +4,8 @@ class InvokeError(ValueError):
description: str | None = None
def __init__(self, description: str | None = None):
self.description = description
if description is not None:
self.description = description
def __str__(self):
return self.description or self.__class__.__name__

View File

@@ -282,7 +282,8 @@ class ModelProviderFactory:
all_model_type_models.append(model_schema)
simple_provider_schema = provider_schema.to_simple_provider()
simple_provider_schema.models.extend(all_model_type_models)
if model_type:
simple_provider_schema.models = all_model_type_models
providers.append(simple_provider_schema)

View File

@@ -72,8 +72,8 @@ class EmailDeliveryConfig(BaseModel):
body: str
debug_mode: bool = False
def with_debug_recipient(self, user_id: str) -> "EmailDeliveryConfig":
if not user_id:
def with_debug_recipient(self, user_id: str | None) -> "EmailDeliveryConfig":
if user_id is None:
debug_recipients = EmailRecipients(whole_workspace=False, items=[])
return self.model_copy(update={"recipients": debug_recipients})
debug_recipients = EmailRecipients(whole_workspace=False, items=[MemberRecipient(user_id=user_id)])
@@ -141,7 +141,7 @@ def apply_debug_email_recipient(
method: DeliveryChannelConfig,
*,
enabled: bool,
user_id: str,
user_id: str | None,
) -> DeliveryChannelConfig:
if not enabled:
return method
@@ -149,7 +149,7 @@ def apply_debug_email_recipient(
return method
if not method.config.debug_mode:
return method
debug_config = method.config.with_debug_recipient(user_id or "")
debug_config = method.config.with_debug_recipient(user_id)
return method.model_copy(update={"config": debug_config})

View File

@@ -952,7 +952,7 @@ class WorkflowService:
delivery_method = apply_debug_email_recipient(
delivery_method,
enabled=True,
user_id=account.id or "",
user_id=account.id,
)
variable_pool = self._build_human_input_variable_pool(

View File

@@ -60,7 +60,6 @@ VECTOR_STORE=weaviate
# Weaviate configuration
WEAVIATE_ENDPOINT=http://localhost:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENABLED=false
WEAVIATE_BATCH_SIZE=100
WEAVIATE_TOKENIZATION=word

View File

@@ -0,0 +1,32 @@
"""
Integration tests for AppModelConfig using testcontainers.
These tests validate database-backed model behavior without mocking SQLAlchemy queries.
"""
from uuid import uuid4
from sqlalchemy.orm import Session
from models.model import AppModelConfig
class TestAppModelConfig:
"""Integration tests for AppModelConfig."""
def test_annotation_reply_dict_disabled_without_setting(self, db_session_with_containers: Session) -> None:
"""Return disabled annotation reply dict when no AppAnnotationSetting exists."""
# Arrange
config = AppModelConfig(app_id=str(uuid4()))
db_session_with_containers.add(config)
db_session_with_containers.commit()
# Act
result = config.annotation_reply_dict
# Assert
assert result == {"enabled": False}
# Cleanup
db_session_with_containers.delete(config)
db_session_with_containers.commit()

View File

@@ -0,0 +1,313 @@
"""
Unit tests for inner_api plugin endpoints
Tests endpoint structure (method existence) for all plugin APIs, plus
handler-level logic tests for representative non-streaming endpoints.
Auth/setup decorators are tested separately in test_auth_wraps.py;
handler tests use inspect.unwrap() to bypass them.
"""
import inspect
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.inner_api.plugin.plugin import (
PluginFetchAppInfoApi,
PluginInvokeAppApi,
PluginInvokeEncryptApi,
PluginInvokeLLMApi,
PluginInvokeLLMWithStructuredOutputApi,
PluginInvokeModerationApi,
PluginInvokeParameterExtractorNodeApi,
PluginInvokeQuestionClassifierNodeApi,
PluginInvokeRerankApi,
PluginInvokeSpeech2TextApi,
PluginInvokeSummaryApi,
PluginInvokeTextEmbeddingApi,
PluginInvokeToolApi,
PluginInvokeTTSApi,
PluginUploadFileRequestApi,
)
def _extract_raw_post(cls):
"""Extract the raw post() method from a plugin endpoint class.
Plugin endpoint methods are wrapped by several decorators (get_user_tenant,
setup_required, plugin_inner_api_only, plugin_data). These decorators
use @wraps where possible. This helper ensures we retrieve the original
post(self, user_model, tenant_model, payload) function by unwrapping
and, if necessary, walking the closure of the innermost wrapper.
"""
bottom = inspect.unwrap(cls.post)
# If unwrap() didn't get us to the raw function (e.g. if a decorator
# missed @wraps), try to extract it from the closure if it looks like
# a plugin_data or similar wrapper that closes over 'view_func'.
if hasattr(bottom, "__code__") and "view_func" in bottom.__code__.co_freevars:
try:
idx = bottom.__code__.co_freevars.index("view_func")
return bottom.__closure__[idx].cell_contents
except (AttributeError, TypeError, IndexError):
pass
return bottom
class TestPluginInvokeLLMApi:
"""Test PluginInvokeLLMApi endpoint structure"""
@pytest.fixture
def api_instance(self):
return PluginInvokeLLMApi()
def test_has_post_method(self, api_instance):
"""Test that endpoint has post method"""
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeLLMWithStructuredOutputApi:
"""Test PluginInvokeLLMWithStructuredOutputApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeLLMWithStructuredOutputApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeTextEmbeddingApi:
"""Test PluginInvokeTextEmbeddingApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeTextEmbeddingApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeRerankApi:
"""Test PluginInvokeRerankApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeRerankApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeTTSApi:
"""Test PluginInvokeTTSApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeTTSApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeSpeech2TextApi:
"""Test PluginInvokeSpeech2TextApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeSpeech2TextApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeModerationApi:
"""Test PluginInvokeModerationApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeModerationApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeToolApi:
"""Test PluginInvokeToolApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeToolApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeParameterExtractorNodeApi:
"""Test PluginInvokeParameterExtractorNodeApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeParameterExtractorNodeApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeQuestionClassifierNodeApi:
"""Test PluginInvokeQuestionClassifierNodeApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeQuestionClassifierNodeApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeAppApi:
"""Test PluginInvokeAppApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeAppApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginInvokeEncryptApi:
"""Test PluginInvokeEncryptApi endpoint structure and handler logic"""
@pytest.fixture
def api_instance(self):
return PluginInvokeEncryptApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
@patch("controllers.inner_api.plugin.plugin.PluginEncrypter")
def test_post_returns_encrypted_data(self, mock_encrypter, api_instance, app: Flask):
"""Test that post() delegates to PluginEncrypter and returns model_dump output"""
# Arrange
mock_encrypter.invoke_encrypt.return_value = {"encrypted": "data"}
mock_tenant = MagicMock()
mock_user = MagicMock()
mock_payload = MagicMock()
# Act — extract raw post() bypassing all decorators including plugin_data
raw_post = _extract_raw_post(PluginInvokeEncryptApi)
result = raw_post(api_instance, user_model=mock_user, tenant_model=mock_tenant, payload=mock_payload)
# Assert
mock_encrypter.invoke_encrypt.assert_called_once_with(mock_tenant, mock_payload)
assert result["data"] == {"encrypted": "data"}
assert result.get("error") == ""
@patch("controllers.inner_api.plugin.plugin.PluginEncrypter")
def test_post_returns_error_on_exception(self, mock_encrypter, api_instance, app: Flask):
"""Test that post() catches exceptions and returns error response"""
# Arrange
mock_encrypter.invoke_encrypt.side_effect = RuntimeError("encrypt failed")
mock_tenant = MagicMock()
mock_user = MagicMock()
mock_payload = MagicMock()
# Act
raw_post = _extract_raw_post(PluginInvokeEncryptApi)
result = raw_post(api_instance, user_model=mock_user, tenant_model=mock_tenant, payload=mock_payload)
# Assert
assert "encrypt failed" in result["error"]
class TestPluginInvokeSummaryApi:
"""Test PluginInvokeSummaryApi endpoint"""
@pytest.fixture
def api_instance(self):
return PluginInvokeSummaryApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
class TestPluginUploadFileRequestApi:
"""Test PluginUploadFileRequestApi endpoint structure and handler logic"""
@pytest.fixture
def api_instance(self):
return PluginUploadFileRequestApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
@patch("controllers.inner_api.plugin.plugin.get_signed_file_url_for_plugin")
def test_post_returns_signed_url(self, mock_get_url, api_instance, app: Flask):
"""Test that post() generates a signed URL and returns it"""
# Arrange
mock_get_url.return_value = "https://storage.example.com/signed-upload-url"
mock_tenant = MagicMock()
mock_tenant.id = "tenant-id"
mock_user = MagicMock()
mock_user.id = "user-id"
mock_payload = MagicMock()
mock_payload.filename = "test.pdf"
mock_payload.mimetype = "application/pdf"
# Act
raw_post = _extract_raw_post(PluginUploadFileRequestApi)
result = raw_post(api_instance, user_model=mock_user, tenant_model=mock_tenant, payload=mock_payload)
# Assert
mock_get_url.assert_called_once_with(
filename="test.pdf", mimetype="application/pdf", tenant_id="tenant-id", user_id="user-id"
)
assert result["data"]["url"] == "https://storage.example.com/signed-upload-url"
class TestPluginFetchAppInfoApi:
"""Test PluginFetchAppInfoApi endpoint structure and handler logic"""
@pytest.fixture
def api_instance(self):
return PluginFetchAppInfoApi()
def test_has_post_method(self, api_instance):
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
@patch("controllers.inner_api.plugin.plugin.PluginAppBackwardsInvocation")
def test_post_returns_app_info(self, mock_invocation, api_instance, app: Flask):
"""Test that post() fetches app info and returns it"""
# Arrange
mock_invocation.fetch_app_info.return_value = {"app_name": "My App", "mode": "chat"}
mock_tenant = MagicMock()
mock_tenant.id = "tenant-id"
mock_user = MagicMock()
mock_payload = MagicMock()
mock_payload.app_id = "app-123"
# Act
raw_post = _extract_raw_post(PluginFetchAppInfoApi)
result = raw_post(api_instance, user_model=mock_user, tenant_model=mock_tenant, payload=mock_payload)
# Assert
mock_invocation.fetch_app_info.assert_called_once_with("app-123", "tenant-id")
assert result["data"] == {"app_name": "My App", "mode": "chat"}

View File

@@ -0,0 +1,305 @@
"""
Unit tests for inner_api plugin decorators
"""
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.plugin.wraps import (
TenantUserPayload,
get_user,
get_user_tenant,
plugin_data,
)
class TestTenantUserPayload:
"""Test TenantUserPayload Pydantic model"""
def test_valid_payload(self):
"""Test valid payload passes validation"""
data = {"tenant_id": "tenant123", "user_id": "user456"}
payload = TenantUserPayload.model_validate(data)
assert payload.tenant_id == "tenant123"
assert payload.user_id == "user456"
def test_missing_tenant_id(self):
"""Test missing tenant_id raises ValidationError"""
with pytest.raises(ValidationError):
TenantUserPayload.model_validate({"user_id": "user456"})
def test_missing_user_id(self):
"""Test missing user_id raises ValidationError"""
with pytest.raises(ValidationError):
TenantUserPayload.model_validate({"tenant_id": "tenant123"})
class TestGetUser:
"""Test get_user function"""
@patch("controllers.inner_api.plugin.wraps.EndUser")
@patch("controllers.inner_api.plugin.wraps.Session")
@patch("controllers.inner_api.plugin.wraps.db")
def test_should_return_existing_user_by_id(self, mock_db, mock_session_class, mock_enduser_class, app: Flask):
"""Test returning existing user when found by ID"""
# Arrange
mock_user = MagicMock()
mock_user.id = "user123"
mock_session = MagicMock()
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.where.return_value.first.return_value = mock_user
# Act
with app.app_context():
result = get_user("tenant123", "user123")
# Assert
assert result == mock_user
mock_session.query.assert_called_once()
@patch("controllers.inner_api.plugin.wraps.EndUser")
@patch("controllers.inner_api.plugin.wraps.Session")
@patch("controllers.inner_api.plugin.wraps.db")
def test_should_return_existing_anonymous_user_by_session_id(
self, mock_db, mock_session_class, mock_enduser_class, app: Flask
):
"""Test returning existing anonymous user by session_id"""
# Arrange
mock_user = MagicMock()
mock_user.session_id = "anonymous_session"
mock_session = MagicMock()
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.where.return_value.first.return_value = mock_user
# Act
with app.app_context():
result = get_user("tenant123", "anonymous_session")
# Assert
assert result == mock_user
@patch("controllers.inner_api.plugin.wraps.EndUser")
@patch("controllers.inner_api.plugin.wraps.Session")
@patch("controllers.inner_api.plugin.wraps.db")
def test_should_create_new_user_when_not_found(self, mock_db, mock_session_class, mock_enduser_class, app: Flask):
"""Test creating new user when not found in database"""
# Arrange
mock_session = MagicMock()
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.where.return_value.first.return_value = None
mock_new_user = MagicMock()
mock_enduser_class.return_value = mock_new_user
# Act
with app.app_context():
result = get_user("tenant123", "user123")
# Assert
assert result == mock_new_user
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
mock_session.refresh.assert_called_once()
@patch("controllers.inner_api.plugin.wraps.EndUser")
@patch("controllers.inner_api.plugin.wraps.Session")
@patch("controllers.inner_api.plugin.wraps.db")
def test_should_use_default_session_id_when_user_id_none(
self, mock_db, mock_session_class, mock_enduser_class, app: Flask
):
"""Test using default session ID when user_id is None"""
# Arrange
mock_user = MagicMock()
mock_session = MagicMock()
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session.query.return_value.where.return_value.first.return_value = mock_user
# Act
with app.app_context():
result = get_user("tenant123", None)
# Assert
assert result == mock_user
@patch("controllers.inner_api.plugin.wraps.EndUser")
@patch("controllers.inner_api.plugin.wraps.Session")
@patch("controllers.inner_api.plugin.wraps.db")
def test_should_raise_error_on_database_exception(
self, mock_db, mock_session_class, mock_enduser_class, app: Flask
):
"""Test raising ValueError when database operation fails"""
# Arrange
mock_session = MagicMock()
mock_session_class.return_value.__enter__.return_value = mock_session
mock_session.query.side_effect = Exception("Database error")
# Act & Assert
with app.app_context():
with pytest.raises(ValueError, match="user not found"):
get_user("tenant123", "user123")
class TestGetUserTenant:
"""Test get_user_tenant decorator"""
@patch("controllers.inner_api.plugin.wraps.Tenant")
def test_should_inject_tenant_and_user_models(self, mock_tenant_class, app: Flask, monkeypatch):
"""Test that decorator injects tenant_model and user_model into kwargs"""
# Arrange
@get_user_tenant
def protected_view(tenant_model, user_model, **kwargs):
return {"tenant": tenant_model, "user": user_model}
mock_tenant = MagicMock()
mock_tenant.id = "tenant123"
mock_user = MagicMock()
mock_user.id = "user456"
# Act
with app.test_request_context(json={"tenant_id": "tenant123", "user_id": "user456"}):
monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False)
with patch("controllers.inner_api.plugin.wraps.db.session.query") as mock_query:
with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user:
mock_query.return_value.where.return_value.first.return_value = mock_tenant
mock_get_user.return_value = mock_user
result = protected_view()
# Assert
assert result["tenant"] == mock_tenant
assert result["user"] == mock_user
def test_should_raise_error_when_tenant_id_missing(self, app: Flask):
"""Test that Pydantic ValidationError is raised when tenant_id is missing from payload"""
# Arrange
@get_user_tenant
def protected_view(tenant_model, user_model, **kwargs):
return "success"
# Act & Assert - Pydantic validates payload before manual check
with app.test_request_context(json={"user_id": "user456"}):
with pytest.raises(ValidationError):
protected_view()
def test_should_raise_error_when_tenant_not_found(self, app: Flask):
"""Test that ValueError is raised when tenant is not found"""
# Arrange
@get_user_tenant
def protected_view(tenant_model, user_model, **kwargs):
return "success"
# Act & Assert
with app.test_request_context(json={"tenant_id": "nonexistent", "user_id": "user456"}):
with patch("controllers.inner_api.plugin.wraps.db.session.query") as mock_query:
mock_query.return_value.where.return_value.first.return_value = None
with pytest.raises(ValueError, match="tenant not found"):
protected_view()
@patch("controllers.inner_api.plugin.wraps.Tenant")
def test_should_use_default_session_id_when_user_id_empty(self, mock_tenant_class, app: Flask, monkeypatch):
"""Test that default session ID is used when user_id is empty string"""
# Arrange
@get_user_tenant
def protected_view(tenant_model, user_model, **kwargs):
return {"tenant": tenant_model, "user": user_model}
mock_tenant = MagicMock()
mock_tenant.id = "tenant123"
mock_user = MagicMock()
# Act - use empty string for user_id to trigger default logic
with app.test_request_context(json={"tenant_id": "tenant123", "user_id": ""}):
monkeypatch.setattr(app, "login_manager", MagicMock(), raising=False)
with patch("controllers.inner_api.plugin.wraps.db.session.query") as mock_query:
with patch("controllers.inner_api.plugin.wraps.get_user") as mock_get_user:
mock_query.return_value.where.return_value.first.return_value = mock_tenant
mock_get_user.return_value = mock_user
result = protected_view()
# Assert
assert result["tenant"] == mock_tenant
assert result["user"] == mock_user
from models.model import DefaultEndUserSessionID
mock_get_user.assert_called_once_with("tenant123", DefaultEndUserSessionID.DEFAULT_SESSION_ID)
class PluginTestPayload:
"""Simple test payload class"""
def __init__(self, data: dict):
self.value = data.get("value")
@classmethod
def model_validate(cls, data: dict):
return cls(data)
class TestPluginData:
"""Test plugin_data decorator"""
def test_should_inject_valid_payload(self, app: Flask):
"""Test that valid payload is injected into kwargs"""
# Arrange
@plugin_data(payload_type=PluginTestPayload)
def protected_view(payload, **kwargs):
return payload
# Act
with app.test_request_context(json={"value": "test_data"}):
result = protected_view()
# Assert
assert result.value == "test_data"
def test_should_raise_error_on_invalid_json(self, app: Flask):
"""Test that ValueError is raised when JSON parsing fails"""
# Arrange
@plugin_data(payload_type=PluginTestPayload)
def protected_view(payload, **kwargs):
return payload
# Act & Assert - Malformed JSON triggers ValueError
with app.test_request_context(data="not valid json", content_type="application/json"):
with pytest.raises(ValueError):
protected_view()
def test_should_raise_error_on_invalid_payload(self, app: Flask):
"""Test that ValueError is raised when payload validation fails"""
# Arrange
class InvalidPayload:
@classmethod
def model_validate(cls, data: dict):
raise Exception("Validation failed")
@plugin_data(payload_type=InvalidPayload)
def protected_view(payload, **kwargs):
return payload
# Act & Assert
with app.test_request_context(json={"data": "test"}):
with pytest.raises(ValueError, match="invalid payload"):
protected_view()
def test_should_work_as_parameterized_decorator(self, app: Flask):
"""Test that decorator works when used with parentheses"""
# Arrange
@plugin_data(payload_type=PluginTestPayload)
def protected_view(payload, **kwargs):
return payload
# Act
with app.test_request_context(json={"value": "parameterized"}):
result = protected_view()
# Assert
assert result.value == "parameterized"

View File

@@ -0,0 +1,309 @@
"""
Unit tests for inner_api auth decorators
"""
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from werkzeug.exceptions import HTTPException
from configs import dify_config
from controllers.inner_api.wraps import (
billing_inner_api_only,
enterprise_inner_api_only,
enterprise_inner_api_user_auth,
plugin_inner_api_only,
)
class TestBillingInnerApiOnly:
"""Test billing_inner_api_only decorator"""
def test_should_allow_when_inner_api_enabled_and_valid_key(self, app: Flask):
"""Test that valid API key allows access when INNER_API is enabled"""
# Arrange
@billing_inner_api_only
def protected_view():
return "success"
# Act
with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
result = protected_view()
# Assert
assert result == "success"
def test_should_return_404_when_inner_api_disabled(self, app: Flask):
"""Test that 404 is returned when INNER_API is disabled"""
# Arrange
@billing_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context():
with patch.object(dify_config, "INNER_API", False):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
def test_should_return_401_when_api_key_missing(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is missing"""
# Arrange
@billing_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context(headers={}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
def test_should_return_401_when_api_key_invalid(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is invalid"""
# Arrange
@billing_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
class TestEnterpriseInnerApiOnly:
"""Test enterprise_inner_api_only decorator"""
def test_should_allow_when_inner_api_enabled_and_valid_key(self, app: Flask):
"""Test that valid API key allows access when INNER_API is enabled"""
# Arrange
@enterprise_inner_api_only
def protected_view():
return "success"
# Act
with app.test_request_context(headers={"X-Inner-Api-Key": "valid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
result = protected_view()
# Assert
assert result == "success"
def test_should_return_404_when_inner_api_disabled(self, app: Flask):
"""Test that 404 is returned when INNER_API is disabled"""
# Arrange
@enterprise_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context():
with patch.object(dify_config, "INNER_API", False):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
def test_should_return_401_when_api_key_missing(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is missing"""
# Arrange
@enterprise_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context(headers={}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
def test_should_return_401_when_api_key_invalid(self, app: Flask):
"""Test that 401 is returned when X-Inner-Api-Key header is invalid"""
# Arrange
@enterprise_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}):
with patch.object(dify_config, "INNER_API", True):
with patch.object(dify_config, "INNER_API_KEY", "valid_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 401
class TestEnterpriseInnerApiUserAuth:
"""Test enterprise_inner_api_user_auth decorator for HMAC-based user authentication"""
def test_should_pass_through_when_inner_api_disabled(self, app: Flask):
"""Test that request passes through when INNER_API is disabled"""
# Arrange
@enterprise_inner_api_user_auth
def protected_view(**kwargs):
return kwargs.get("user", "no_user")
# Act
with app.test_request_context():
with patch.object(dify_config, "INNER_API", False):
result = protected_view()
# Assert
assert result == "no_user"
def test_should_pass_through_when_authorization_header_missing(self, app: Flask):
"""Test that request passes through when Authorization header is missing"""
# Arrange
@enterprise_inner_api_user_auth
def protected_view(**kwargs):
return kwargs.get("user", "no_user")
# Act
with app.test_request_context(headers={}):
with patch.object(dify_config, "INNER_API", True):
result = protected_view()
# Assert
assert result == "no_user"
def test_should_pass_through_when_authorization_format_invalid(self, app: Flask):
"""Test that request passes through when Authorization format is invalid (no colon)"""
# Arrange
@enterprise_inner_api_user_auth
def protected_view(**kwargs):
return kwargs.get("user", "no_user")
# Act
with app.test_request_context(headers={"Authorization": "invalid_format"}):
with patch.object(dify_config, "INNER_API", True):
result = protected_view()
# Assert
assert result == "no_user"
def test_should_pass_through_when_hmac_signature_invalid(self, app: Flask):
"""Test that request passes through when HMAC signature is invalid"""
# Arrange
@enterprise_inner_api_user_auth
def protected_view(**kwargs):
return kwargs.get("user", "no_user")
# Act - use wrong signature
with app.test_request_context(
headers={"Authorization": "Bearer user123:wrong_signature", "X-Inner-Api-Key": "valid_key"}
):
with patch.object(dify_config, "INNER_API", True):
result = protected_view()
# Assert
assert result == "no_user"
def test_should_inject_user_when_hmac_signature_valid(self, app: Flask):
"""Test that user is injected when HMAC signature is valid"""
# Arrange
from base64 import b64encode
from hashlib import sha1
from hmac import new as hmac_new
@enterprise_inner_api_user_auth
def protected_view(**kwargs):
return kwargs.get("user")
# Calculate valid HMAC signature
user_id = "user123"
inner_api_key = "valid_key"
data_to_sign = f"DIFY {user_id}"
signature = hmac_new(inner_api_key.encode("utf-8"), data_to_sign.encode("utf-8"), sha1)
valid_signature = b64encode(signature.digest()).decode("utf-8")
# Create mock user
mock_user = MagicMock()
mock_user.id = user_id
# Act
with app.test_request_context(
headers={"Authorization": f"Bearer {user_id}:{valid_signature}", "X-Inner-Api-Key": inner_api_key}
):
with patch.object(dify_config, "INNER_API", True):
with patch("controllers.inner_api.wraps.db.session.query") as mock_query:
mock_query.return_value.where.return_value.first.return_value = mock_user
result = protected_view()
# Assert
assert result == mock_user
class TestPluginInnerApiOnly:
"""Test plugin_inner_api_only decorator"""
def test_should_allow_when_plugin_daemon_key_set_and_valid_key(self, app: Flask):
"""Test that valid API key allows access when PLUGIN_DAEMON_KEY is set"""
# Arrange
@plugin_inner_api_only
def protected_view():
return "success"
# Act
with app.test_request_context(headers={"X-Inner-Api-Key": "valid_plugin_key"}):
with patch.object(dify_config, "PLUGIN_DAEMON_KEY", "plugin_key"):
with patch.object(dify_config, "INNER_API_KEY_FOR_PLUGIN", "valid_plugin_key"):
result = protected_view()
# Assert
assert result == "success"
def test_should_return_404_when_plugin_daemon_key_not_set(self, app: Flask):
"""Test that 404 is returned when PLUGIN_DAEMON_KEY is not set"""
# Arrange
@plugin_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context():
with patch.object(dify_config, "PLUGIN_DAEMON_KEY", ""):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404
def test_should_return_404_when_api_key_invalid(self, app: Flask):
"""Test that 404 is returned when X-Inner-Api-Key header is invalid (note: returns 404, not 401)"""
# Arrange
@plugin_inner_api_only
def protected_view():
return "success"
# Act & Assert
with app.test_request_context(headers={"X-Inner-Api-Key": "invalid_key"}):
with patch.object(dify_config, "PLUGIN_DAEMON_KEY", "plugin_key"):
with patch.object(dify_config, "INNER_API_KEY_FOR_PLUGIN", "valid_plugin_key"):
with pytest.raises(HTTPException) as exc_info:
protected_view()
assert exc_info.value.code == 404

View File

@@ -0,0 +1,206 @@
"""
Unit tests for inner_api mail module
"""
from unittest.mock import patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.mail import (
BaseMail,
BillingMail,
EnterpriseMail,
InnerMailPayload,
)
class TestInnerMailPayload:
"""Test InnerMailPayload Pydantic model"""
def test_valid_payload_with_all_fields(self):
"""Test valid payload with all fields passes validation"""
data = {
"to": ["test@example.com"],
"subject": "Test Subject",
"body": "Test Body",
"substitutions": {"key": "value"},
}
payload = InnerMailPayload.model_validate(data)
assert payload.to == ["test@example.com"]
assert payload.subject == "Test Subject"
assert payload.body == "Test Body"
assert payload.substitutions == {"key": "value"}
def test_valid_payload_without_substitutions(self):
"""Test valid payload without optional substitutions"""
data = {
"to": ["test@example.com"],
"subject": "Test Subject",
"body": "Test Body",
}
payload = InnerMailPayload.model_validate(data)
assert payload.to == ["test@example.com"]
assert payload.subject == "Test Subject"
assert payload.body == "Test Body"
assert payload.substitutions is None
def test_empty_to_list_fails_validation(self):
"""Test that empty 'to' list fails validation due to min_length=1"""
data = {
"to": [],
"subject": "Test Subject",
"body": "Test Body",
}
with pytest.raises(ValidationError):
InnerMailPayload.model_validate(data)
def test_multiple_recipients_allowed(self):
"""Test that multiple recipients are allowed"""
data = {
"to": ["user1@example.com", "user2@example.com"],
"subject": "Test Subject",
"body": "Test Body",
}
payload = InnerMailPayload.model_validate(data)
assert len(payload.to) == 2
assert "user1@example.com" in payload.to
assert "user2@example.com" in payload.to
def test_missing_to_field_fails_validation(self):
"""Test that missing 'to' field fails validation"""
data = {
"subject": "Test Subject",
"body": "Test Body",
}
with pytest.raises(ValidationError):
InnerMailPayload.model_validate(data)
def test_missing_subject_fails_validation(self):
"""Test that missing 'subject' field fails validation"""
data = {
"to": ["test@example.com"],
"body": "Test Body",
}
with pytest.raises(ValidationError):
InnerMailPayload.model_validate(data)
def test_missing_body_fails_validation(self):
"""Test that missing 'body' field fails validation"""
data = {
"to": ["test@example.com"],
"subject": "Test Subject",
}
with pytest.raises(ValidationError):
InnerMailPayload.model_validate(data)
class TestBaseMail:
"""Test BaseMail API endpoint"""
@pytest.fixture
def api_instance(self):
"""Create BaseMail API instance"""
return BaseMail()
@patch("controllers.inner_api.mail.send_inner_email_task")
def test_post_sends_email_task(self, mock_task, api_instance, app: Flask):
"""Test that POST sends inner email task"""
# Arrange
mock_task.delay.return_value = None
# Act
with app.test_request_context(
json={
"to": ["test@example.com"],
"subject": "Test Subject",
"body": "Test Body",
}
):
with patch("controllers.inner_api.mail.inner_api_ns") as mock_ns:
mock_ns.payload = {
"to": ["test@example.com"],
"subject": "Test Subject",
"body": "Test Body",
}
result = api_instance.post()
# Assert
assert result == ({"message": "success"}, 200)
mock_task.delay.assert_called_once_with(
to=["test@example.com"],
subject="Test Subject",
body="Test Body",
substitutions=None,
)
@patch("controllers.inner_api.mail.send_inner_email_task")
def test_post_with_substitutions(self, mock_task, api_instance, app: Flask):
"""Test that POST sends email with substitutions"""
# Arrange
mock_task.delay.return_value = None
# Act
with app.test_request_context():
with patch("controllers.inner_api.mail.inner_api_ns") as mock_ns:
mock_ns.payload = {
"to": ["test@example.com"],
"subject": "Hello {{name}}",
"body": "Welcome {{name}}!",
"substitutions": {"name": "John"},
}
result = api_instance.post()
# Assert
assert result == ({"message": "success"}, 200)
mock_task.delay.assert_called_once_with(
to=["test@example.com"],
subject="Hello {{name}}",
body="Welcome {{name}}!",
substitutions={"name": "John"},
)
class TestEnterpriseMail:
"""Test EnterpriseMail API endpoint"""
@pytest.fixture
def api_instance(self):
"""Create EnterpriseMail API instance"""
return EnterpriseMail()
def test_has_enterprise_inner_api_only_decorator(self, api_instance):
"""Test that EnterpriseMail has enterprise_inner_api_only decorator"""
# Check method_decorators
from controllers.inner_api.wraps import enterprise_inner_api_only
assert enterprise_inner_api_only in api_instance.method_decorators
def test_has_setup_required_decorator(self, api_instance):
"""Test that EnterpriseMail has setup_required decorator"""
# Check by decorator name instead of object reference
decorator_names = [d.__name__ for d in api_instance.method_decorators]
assert "setup_required" in decorator_names
class TestBillingMail:
"""Test BillingMail API endpoint"""
@pytest.fixture
def api_instance(self):
"""Create BillingMail API instance"""
return BillingMail()
def test_has_billing_inner_api_only_decorator(self, api_instance):
"""Test that BillingMail has billing_inner_api_only decorator"""
# Check method_decorators
from controllers.inner_api.wraps import billing_inner_api_only
assert billing_inner_api_only in api_instance.method_decorators
def test_has_setup_required_decorator(self, api_instance):
"""Test that BillingMail has setup_required decorator"""
# Check by decorator name instead of object reference
decorator_names = [d.__name__ for d in api_instance.method_decorators]
assert "setup_required" in decorator_names

View File

@@ -0,0 +1,184 @@
"""
Unit tests for inner_api workspace module
Tests Pydantic model validation and endpoint handler logic.
Auth/setup decorators are tested separately in test_auth_wraps.py;
handler tests use inspect.unwrap() to bypass them and focus on business logic.
"""
import inspect
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.inner_api.workspace.workspace import (
EnterpriseWorkspace,
EnterpriseWorkspaceNoOwnerEmail,
WorkspaceCreatePayload,
WorkspaceOwnerlessPayload,
)
class TestWorkspaceCreatePayload:
"""Test WorkspaceCreatePayload Pydantic model validation"""
def test_valid_payload(self):
"""Test valid payload with all fields passes validation"""
data = {
"name": "My Workspace",
"owner_email": "owner@example.com",
}
payload = WorkspaceCreatePayload.model_validate(data)
assert payload.name == "My Workspace"
assert payload.owner_email == "owner@example.com"
def test_missing_name_fails_validation(self):
"""Test that missing name fails validation"""
data = {"owner_email": "owner@example.com"}
with pytest.raises(ValidationError) as exc_info:
WorkspaceCreatePayload.model_validate(data)
assert "name" in str(exc_info.value)
def test_missing_owner_email_fails_validation(self):
"""Test that missing owner_email fails validation"""
data = {"name": "My Workspace"}
with pytest.raises(ValidationError) as exc_info:
WorkspaceCreatePayload.model_validate(data)
assert "owner_email" in str(exc_info.value)
class TestWorkspaceOwnerlessPayload:
"""Test WorkspaceOwnerlessPayload Pydantic model validation"""
def test_valid_payload(self):
"""Test valid payload with name passes validation"""
data = {"name": "My Workspace"}
payload = WorkspaceOwnerlessPayload.model_validate(data)
assert payload.name == "My Workspace"
def test_missing_name_fails_validation(self):
"""Test that missing name fails validation"""
data = {}
with pytest.raises(ValidationError) as exc_info:
WorkspaceOwnerlessPayload.model_validate(data)
assert "name" in str(exc_info.value)
class TestEnterpriseWorkspace:
"""Test EnterpriseWorkspace API endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators (tested in test_auth_wraps.py)
and exercise the core business logic directly.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseWorkspace()
def test_has_post_method(self, api_instance):
"""Test that EnterpriseWorkspace has post method"""
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
@patch("controllers.inner_api.workspace.workspace.tenant_was_created")
@patch("controllers.inner_api.workspace.workspace.TenantService")
@patch("controllers.inner_api.workspace.workspace.db")
def test_post_creates_workspace_with_owner(self, mock_db, mock_tenant_svc, mock_event, api_instance, app: Flask):
"""Test that post() creates a workspace and assigns the owner account"""
# Arrange
mock_account = MagicMock()
mock_account.email = "owner@example.com"
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
now = datetime(2025, 1, 1, 12, 0, 0)
mock_tenant = MagicMock()
mock_tenant.id = "tenant-id"
mock_tenant.name = "My Workspace"
mock_tenant.plan = "sandbox"
mock_tenant.status = "normal"
mock_tenant.created_at = now
mock_tenant.updated_at = now
mock_tenant_svc.create_tenant.return_value = mock_tenant
# Act — unwrap to bypass auth/setup decorators (tested in test_auth_wraps.py)
unwrapped_post = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.workspace.workspace.inner_api_ns") as mock_ns:
mock_ns.payload = {"name": "My Workspace", "owner_email": "owner@example.com"}
result = unwrapped_post(api_instance)
# Assert
assert result["message"] == "enterprise workspace created."
assert result["tenant"]["id"] == "tenant-id"
assert result["tenant"]["name"] == "My Workspace"
mock_tenant_svc.create_tenant.assert_called_once_with("My Workspace", is_from_dashboard=True)
mock_tenant_svc.create_tenant_member.assert_called_once_with(mock_tenant, mock_account, role="owner")
mock_event.send.assert_called_once_with(mock_tenant)
@patch("controllers.inner_api.workspace.workspace.db")
def test_post_returns_404_when_owner_not_found(self, mock_db, api_instance, app: Flask):
"""Test that post() returns 404 when the owner account does not exist"""
# Arrange
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
# Act
unwrapped_post = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.workspace.workspace.inner_api_ns") as mock_ns:
mock_ns.payload = {"name": "My Workspace", "owner_email": "missing@example.com"}
result = unwrapped_post(api_instance)
# Assert
assert result == ({"message": "owner account not found."}, 404)
class TestEnterpriseWorkspaceNoOwnerEmail:
"""Test EnterpriseWorkspaceNoOwnerEmail API endpoint handler logic.
Uses inspect.unwrap() to bypass auth/setup decorators (tested in test_auth_wraps.py)
and exercise the core business logic directly.
"""
@pytest.fixture
def api_instance(self):
return EnterpriseWorkspaceNoOwnerEmail()
def test_has_post_method(self, api_instance):
"""Test that endpoint has post method"""
assert hasattr(api_instance, "post")
assert callable(api_instance.post)
@patch("controllers.inner_api.workspace.workspace.tenant_was_created")
@patch("controllers.inner_api.workspace.workspace.TenantService")
def test_post_creates_ownerless_workspace(self, mock_tenant_svc, mock_event, api_instance, app: Flask):
"""Test that post() creates a workspace without an owner and returns expected fields"""
# Arrange
now = datetime(2025, 1, 1, 12, 0, 0)
mock_tenant = MagicMock()
mock_tenant.id = "tenant-id"
mock_tenant.name = "My Workspace"
mock_tenant.encrypt_public_key = "pub-key"
mock_tenant.plan = "sandbox"
mock_tenant.status = "normal"
mock_tenant.custom_config = None
mock_tenant.created_at = now
mock_tenant.updated_at = now
mock_tenant_svc.create_tenant.return_value = mock_tenant
# Act — unwrap to bypass auth/setup decorators (tested in test_auth_wraps.py)
unwrapped_post = inspect.unwrap(api_instance.post)
with app.test_request_context():
with patch("controllers.inner_api.workspace.workspace.inner_api_ns") as mock_ns:
mock_ns.payload = {"name": "My Workspace"}
result = unwrapped_post(api_instance)
# Assert
assert result["message"] == "enterprise workspace created."
assert result["tenant"]["id"] == "tenant-id"
assert result["tenant"]["encrypt_public_key"] == "pub-key"
assert result["tenant"]["custom_config"] == {}
mock_tenant_svc.create_tenant.assert_called_once_with("My Workspace", is_from_dashboard=True)
mock_event.send.assert_called_once_with(mock_tenant)

View File

@@ -0,0 +1,80 @@
import pytest
class DummyTool:
def __init__(self, name):
self.name = name
class DummyPromptEntity:
def __init__(self, first_prompt):
self.first_prompt = first_prompt
class DummyAgentConfig:
def __init__(self, prompt_entity=None):
self.prompt = prompt_entity
class DummyAppConfig:
def __init__(self, agent=None):
self.agent = agent
class DummyScratchpadUnit:
def __init__(
self,
final=False,
thought=None,
action_str=None,
observation=None,
agent_response=None,
):
self._final = final
self.thought = thought
self.action_str = action_str
self.observation = observation
self.agent_response = agent_response
def is_final(self):
return self._final
@pytest.fixture
def dummy_tool_factory():
def _factory(name):
return DummyTool(name)
return _factory
@pytest.fixture
def dummy_prompt_entity_factory():
def _factory(first_prompt):
return DummyPromptEntity(first_prompt)
return _factory
@pytest.fixture
def dummy_agent_config_factory():
def _factory(prompt_entity=None):
return DummyAgentConfig(prompt_entity)
return _factory
@pytest.fixture
def dummy_app_config_factory():
def _factory(agent=None):
return DummyAppConfig(agent)
return _factory
@pytest.fixture
def dummy_scratchpad_unit_factory():
def _factory(**kwargs):
return DummyScratchpadUnit(**kwargs)
return _factory

View File

@@ -1,70 +1,255 @@
"""Unit tests for CotAgentOutputParser.
Verifies expected parsing behavior for streaming content and JSON payloads,
including edge cases such as empty/non-string content and malformed JSON.
Assumes lightweight fixtures (SimpleNamespace/MagicMock) stand in for real
model output structures. Implementation under test:
core.agent.output_parser.cot_output_parser.CotAgentOutputParser.
"""
import json
from collections.abc import Generator
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.agent.entities import AgentScratchpadUnit
from core.agent.output_parser.cot_output_parser import CotAgentOutputParser
from dify_graph.model_runtime.entities.llm_entities import AssistantPromptMessage, LLMResultChunk, LLMResultChunkDelta
def mock_llm_response(text) -> Generator[LLMResultChunk, None, None]:
for i in range(len(text)):
yield LLMResultChunk(
model="model",
prompt_messages=[],
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=text[i], tool_calls=[])),
@pytest.fixture
def mock_action_class(mocker):
mock_action = MagicMock()
mocker.patch(
"core.agent.output_parser.cot_output_parser.AgentScratchpadUnit.Action",
mock_action,
)
return mock_action
@pytest.fixture
def usage_dict():
return {}
@pytest.fixture
def make_chunk():
def _make_chunk(content=None, usage=None):
delta = SimpleNamespace(
message=SimpleNamespace(content=content),
usage=usage,
)
return SimpleNamespace(delta=delta)
return _make_chunk
def test_cot_output_parser():
test_cases = [
{
"input": 'Through: abc\nAction: ```{"action": "Final Answer", "action_input": "```echarts\n {}\n```"}```',
"action": {"action": "Final Answer", "action_input": "```echarts\n {}\n```"},
"output": 'Through: abc\n {"action": "Final Answer", "action_input": "```echarts\\n {}\\n```"}',
},
# code block with json
{
"input": 'Through: abc\nAction: ```json\n{"action": "Final Answer", "action_input": "```echarts\n {'
'}\n```"}```',
"action": {"action": "Final Answer", "action_input": "```echarts\n {}\n```"},
"output": 'Through: abc\n {"action": "Final Answer", "action_input": "```echarts\\n {}\\n```"}',
},
# code block with JSON
{
"input": 'Through: abc\nAction: ```JSON\n{"action": "Final Answer", "action_input": "```echarts\n {'
'}\n```"}```',
"action": {"action": "Final Answer", "action_input": "```echarts\n {}\n```"},
"output": 'Through: abc\n {"action": "Final Answer", "action_input": "```echarts\\n {}\\n```"}',
},
# list
{
"input": 'Through: abc\nAction: ```[{"action": "Final Answer", "action_input": "```echarts\n {}\n```"}]```',
"action": {"action": "Final Answer", "action_input": "```echarts\n {}\n```"},
"output": 'Through: abc\n {"action": "Final Answer", "action_input": "```echarts\\n {}\\n```"}',
},
# no code block
{
"input": 'Through: abc\nAction: {"action": "Final Answer", "action_input": "```echarts\n {}\n```"}',
"action": {"action": "Final Answer", "action_input": "```echarts\n {}\n```"},
"output": 'Through: abc\n {"action": "Final Answer", "action_input": "```echarts\\n {}\\n```"}',
},
# no code block and json
{"input": "Through: abc\nAction: efg", "action": {}, "output": "Through: abc\n efg"},
]
# ============================================================
# Test Suite
# ============================================================
parser = CotAgentOutputParser()
usage_dict = {}
for test_case in test_cases:
# mock llm_response as a generator by text
llm_response: Generator[LLMResultChunk, None, None] = mock_llm_response(test_case["input"])
results = parser.handle_react_stream_output(llm_response, usage_dict)
output = ""
for result in results:
if isinstance(result, str):
output += result
elif isinstance(result, AgentScratchpadUnit.Action):
if test_case["action"]:
assert result.to_dict() == test_case["action"]
output += json.dumps(result.to_dict())
if test_case["output"]:
assert output == test_case["output"]
class TestCotAgentOutputParser:
"""Validate CotAgentOutputParser streaming + JSON parsing behavior.
Lifecycle: no explicit setup/teardown; relies on pytest fixtures for
lightweight chunk/action doubles. Invariants: non-string/empty content
yields no output, usage gets recorded when provided, and valid action JSON
results in Action instantiation. Usage: invoke via pytest (e.g.,
`pytest -k TestCotAgentOutputParser`).
"""
# --------------------------------------------------------
# Basic streaming & usage
# --------------------------------------------------------
def test_stream_plain_text(self, make_chunk, usage_dict) -> None:
chunks = [make_chunk("hello world")]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert "".join(result) == "hello world"
def test_stream_empty_string(self, make_chunk, usage_dict) -> None:
chunks = [make_chunk("")]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert result == []
def test_stream_none_content(self, make_chunk, usage_dict) -> None:
chunks = [make_chunk(None)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert result == []
@pytest.mark.parametrize("content", [123, 12.5, [], {}, object()])
def test_non_string_content(self, make_chunk, usage_dict, content) -> None:
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert result == []
def test_usage_update(self, make_chunk, usage_dict) -> None:
usage_data = {"tokens": 99}
chunks = [make_chunk("abc", usage=usage_data)]
list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert usage_dict["usage"] == usage_data
# --------------------------------------------------------
# JSON parsing (direct + streaming)
# --------------------------------------------------------
def test_single_json_action_valid(self, make_chunk, usage_dict, mock_action_class) -> None:
content = '{"action": "search", "input": "query"}'
chunks = [make_chunk(content)]
list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
mock_action_class.assert_called_once_with(action_name="search", action_input="query")
def test_json_list_unwrap(self, make_chunk, usage_dict, mock_action_class) -> None:
content = '[{"action": "lookup", "input": "abc"}]'
chunks = [make_chunk(content)]
list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
mock_action_class.assert_called_once_with(action_name="lookup", action_input="abc")
def test_json_missing_fields_returns_string(self, make_chunk, usage_dict) -> None:
content = '{"foo": "bar"}'
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
# Expect the serialized JSON to be yielded as a single element.
assert result == [json.dumps({"foo": "bar"})]
def test_invalid_json_string_input(self, make_chunk, usage_dict) -> None:
content = "{invalid json}"
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert any("invalid json" in str(r) for r in result)
def test_json_split_across_chunks(self, make_chunk, usage_dict, mock_action_class) -> None:
chunks = [
make_chunk('{"action": '),
make_chunk('"multi", '),
make_chunk('"input": "step"}'),
]
list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
mock_action_class.assert_called_once_with(action_name="multi", action_input="step")
def test_unclosed_json_at_end(self, make_chunk, usage_dict) -> None:
chunks = [make_chunk('{"foo": "bar"')]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert all(isinstance(item, str) for item in result)
assert any('{"foo": "bar"' in item for item in result)
# --------------------------------------------------------
# Code block JSON extraction
# --------------------------------------------------------
def test_code_block_json_valid(self, make_chunk, usage_dict, mock_action_class) -> None:
content = """```json
{"action": "lookup", "input": "abc"}
```"""
chunks = [make_chunk(content)]
list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
mock_action_class.assert_called_once_with(action_name="lookup", action_input="abc")
def test_code_block_multiple_json(self, make_chunk, usage_dict, mock_action_class) -> None:
# Multiple JSON objects inside single code fence (invalid combined JSON)
# Parser should safely ignore invalid combined block
content = """```json
{"action": "a1", "input": "x"}
{"action": "a2", "input": "y"}
```"""
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
# No valid parsed action expected due to invalid combined JSON
assert mock_action_class.call_count == 0
assert isinstance(result, list)
def test_code_block_invalid_json(self, make_chunk, usage_dict) -> None:
content = """```json
{invalid}
```"""
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert result
def test_unclosed_code_block(self, make_chunk, usage_dict) -> None:
chunks = [make_chunk('```json {"a":1}')]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert all(isinstance(item, str) for item in result)
assert any('```json {"a":1}' in item for item in result)
# --------------------------------------------------------
# Action / Thought prefix handling
# --------------------------------------------------------
@pytest.mark.parametrize(
"content",
[
" action: something",
" ACTION: something",
" thought: reasoning",
" THOUGHT: reasoning",
],
)
def test_prefix_handling(self, make_chunk, usage_dict, content) -> None:
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
joined = "".join(str(item) for item in result)
expected_word = "something" if "action:" in content.lower() else "reasoning"
assert expected_word in joined
assert "action:" not in joined.lower()
assert "thought:" not in joined.lower()
def test_prefix_mid_word_yield_delta_branch(self, make_chunk, usage_dict) -> None:
chunks = [make_chunk("xaction: test")]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert "x" in "".join(map(str, result))
# --------------------------------------------------------
# Mixed streaming scenarios
# --------------------------------------------------------
def test_text_json_text_mix(self, make_chunk, usage_dict, mock_action_class) -> None:
content = 'start {"action": "mix", "input": "1"} end'
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
# JSON action should be parsed
mock_action_class.assert_called_once()
# Ensure surrounding text is streamed (character-level)
joined = "".join(str(r) for r in result if not isinstance(r, MagicMock))
assert "start" in joined
assert "end" in joined
def test_multiple_code_blocks_in_stream(self, make_chunk, usage_dict, mock_action_class) -> None:
content = '```json\n{"action":"a1","input":"x"}\n```middle```json\n{"action":"a2","input":"y"}\n```'
chunks = [make_chunk(content)]
list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert mock_action_class.call_count == 2
def test_backtick_noise(self, make_chunk, usage_dict) -> None:
chunks = [make_chunk("text with ` random ` backticks")]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert "text with" in "".join(result)
# --------------------------------------------------------
# Boundary & edge inputs
# --------------------------------------------------------
@pytest.mark.parametrize(
"content",
[
"```",
"{",
"}",
"```json",
"action:",
"thought:",
" ",
],
)
def test_edge_inputs(self, make_chunk, usage_dict, content) -> None:
chunks = [make_chunk(content)]
result = list(CotAgentOutputParser.handle_react_stream_output(chunks, usage_dict))
assert all(isinstance(item, str) for item in result)
joined = "".join(result)
if content == " ":
assert result == [] or joined == content
if content in {"```", "{", "}", "```json"}:
assert content in joined
if content.lower() in {"action:", "thought:"}:
assert "action:" not in joined.lower()
assert "thought:" not in joined.lower()

View File

@@ -0,0 +1,174 @@
from collections.abc import Generator
from unittest.mock import MagicMock
import pytest
from core.agent.strategy.base import BaseAgentStrategy
class DummyStrategy(BaseAgentStrategy):
"""
Concrete implementation for testing BaseAgentStrategy
"""
def __init__(self, return_values=None, raise_exception=None):
self.return_values = return_values or []
self.raise_exception = raise_exception
self.received_args = None
def _invoke(
self,
params,
user_id,
conversation_id=None,
app_id=None,
message_id=None,
credentials=None,
) -> Generator:
self.received_args = (
params,
user_id,
conversation_id,
app_id,
message_id,
credentials,
)
if self.raise_exception:
raise self.raise_exception
yield from self.return_values
class TestBaseAgentStrategyInstantiation:
def test_cannot_instantiate_abstract_class(self) -> None:
with pytest.raises(TypeError):
BaseAgentStrategy()
class TestBaseAgentStrategyInvoke:
@pytest.fixture
def mock_message(self):
return MagicMock(name="AgentInvokeMessage")
@pytest.fixture
def mock_credentials(self):
return MagicMock(name="InvokeCredentials")
@pytest.mark.parametrize(
("params", "user_id", "conversation_id", "app_id", "message_id"),
[
({"key": "value"}, "user1", "conv1", "app1", "msg1"),
({}, "user2", None, None, None),
({"a": 1}, "", "", "", ""),
({"nested": {"x": 1}}, "user3", None, "app3", None),
],
)
def test_invoke_success(
self,
mock_message,
mock_credentials,
params,
user_id,
conversation_id,
app_id,
message_id,
) -> None:
# Arrange
strategy = DummyStrategy(return_values=[mock_message])
# Act
result = list(
strategy.invoke(
params=params,
user_id=user_id,
conversation_id=conversation_id,
app_id=app_id,
message_id=message_id,
credentials=mock_credentials,
)
)
# Assert
assert result == [mock_message]
assert strategy.received_args == (
params,
user_id,
conversation_id,
app_id,
message_id,
mock_credentials,
)
def test_invoke_multiple_yields(self, mock_message) -> None:
# Arrange
messages = [mock_message, MagicMock(), MagicMock()]
strategy = DummyStrategy(return_values=messages)
# Act
result = list(strategy.invoke(params={}, user_id="user"))
# Assert
assert result == messages
def test_invoke_empty_generator(self) -> None:
# Arrange
strategy = DummyStrategy(return_values=[])
# Act
result = list(strategy.invoke(params={}, user_id="user"))
# Assert
assert result == []
def test_invoke_propagates_exception(self) -> None:
# Arrange
strategy = DummyStrategy(raise_exception=ValueError("failure"))
# Act & Assert
with pytest.raises(ValueError, match="failure"):
list(strategy.invoke(params={}, user_id="user"))
@pytest.mark.parametrize(
"invalid_params",
[
None,
"",
123,
[],
],
)
def test_invoke_invalid_params_type_pass_through(self, invalid_params) -> None:
"""
Base class does not validate types — ensure pass-through behavior
"""
strategy = DummyStrategy(return_values=[])
result = list(strategy.invoke(params=invalid_params, user_id="user"))
assert result == []
def test_invoke_none_user_id(self) -> None:
strategy = DummyStrategy(return_values=[])
result = list(strategy.invoke(params={}, user_id=None))
assert result == []
class TestBaseAgentStrategyGetParameters:
def test_get_parameters_default_empty_list(self) -> None:
strategy = DummyStrategy()
result = strategy.get_parameters()
assert isinstance(result, list)
assert result == []
def test_get_parameters_returns_new_list_each_time(self) -> None:
strategy = DummyStrategy()
first = strategy.get_parameters()
second = strategy.get_parameters()
assert first == second == []
assert first is not second

View File

@@ -0,0 +1,272 @@
# File: tests/unit_tests/core/agent/strategy/test_plugin.py
from unittest.mock import MagicMock
import pytest
from core.agent.strategy.plugin import PluginAgentStrategy
# ============================================================
# Fixtures
# ============================================================
@pytest.fixture
def mock_parameter():
def _factory(name="param", return_value="initialized"):
param = MagicMock()
param.name = name
param.init_frontend_parameter = MagicMock(return_value=return_value)
return param
return _factory
@pytest.fixture
def mock_declaration(mock_parameter):
param1 = mock_parameter("param1", "init1")
param2 = mock_parameter("param2", "init2")
identity = MagicMock()
identity.provider = "provider_x"
identity.name = "strategy_x"
declaration = MagicMock()
declaration.parameters = [param1, param2]
declaration.identity = identity
return declaration
@pytest.fixture
def strategy(mock_declaration):
return PluginAgentStrategy(
tenant_id="tenant_123",
declaration=mock_declaration,
meta_version="v1",
)
# ============================================================
# Initialization Tests
# ============================================================
class TestPluginAgentStrategyInitialization:
def test_init_sets_attributes(self, mock_declaration) -> None:
strategy = PluginAgentStrategy(
tenant_id="tenant_test",
declaration=mock_declaration,
meta_version="meta_v",
)
assert strategy.tenant_id == "tenant_test"
assert strategy.declaration == mock_declaration
assert strategy.meta_version == "meta_v"
def test_init_meta_version_none(self, mock_declaration) -> None:
strategy = PluginAgentStrategy(
tenant_id="tenant_test",
declaration=mock_declaration,
meta_version=None,
)
assert strategy.meta_version is None
# ============================================================
# get_parameters Tests
# ============================================================
class TestGetParameters:
def test_get_parameters_returns_parameters(self, strategy, mock_declaration) -> None:
result = strategy.get_parameters()
assert result == mock_declaration.parameters
# ============================================================
# initialize_parameters Tests
# ============================================================
class TestInitializeParameters:
def test_initialize_parameters_success(self, strategy, mock_declaration) -> None:
params = {"param1": "value1"}
result = strategy.initialize_parameters(params.copy())
assert result["param1"] == "init1"
assert result["param2"] == "init2"
mock_declaration.parameters[0].init_frontend_parameter.assert_called_once_with("value1")
mock_declaration.parameters[1].init_frontend_parameter.assert_called_once_with(None)
@pytest.mark.parametrize(
"input_params",
[
{},
{"param1": None},
{"param1": ""},
{"param1": 0},
{"param1": []},
{"param1": {}, "param2": "value"},
],
)
def test_initialize_parameters_edge_cases(self, strategy, input_params) -> None:
result = strategy.initialize_parameters(input_params.copy())
for param in strategy.declaration.parameters:
assert param.name in result
def test_initialize_parameters_invalid_input_type(self, strategy) -> None:
with pytest.raises(AttributeError):
strategy.initialize_parameters(None)
# ============================================================
# _invoke Tests
# ============================================================
class TestInvoke:
def test_invoke_success_all_arguments(self, strategy, mocker) -> None:
mock_manager = MagicMock()
mock_manager.invoke = MagicMock(return_value=iter(["msg1", "msg2"]))
mocker.patch(
"core.agent.strategy.plugin.PluginAgentClient",
return_value=mock_manager,
)
mock_convert = mocker.patch(
"core.agent.strategy.plugin.convert_parameters_to_plugin_format",
return_value={"converted": True},
)
result = list(
strategy._invoke(
params={"param1": "value"},
user_id="user_1",
conversation_id="conv_1",
app_id="app_1",
message_id="msg_1",
credentials=None,
)
)
assert result == ["msg1", "msg2"]
mock_convert.assert_called_once()
mock_manager.invoke.assert_called_once()
call_kwargs = mock_manager.invoke.call_args.kwargs
assert call_kwargs["tenant_id"] == "tenant_123"
assert call_kwargs["user_id"] == "user_1"
assert call_kwargs["agent_provider"] == "provider_x"
assert call_kwargs["agent_strategy"] == "strategy_x"
assert call_kwargs["agent_params"] == {"converted": True}
assert call_kwargs["conversation_id"] == "conv_1"
assert call_kwargs["app_id"] == "app_1"
assert call_kwargs["message_id"] == "msg_1"
assert call_kwargs["context"] is not None
def test_invoke_with_credentials(self, strategy, mocker) -> None:
mock_manager = MagicMock()
mock_manager.invoke = MagicMock(return_value=iter([]))
mocker.patch(
"core.agent.strategy.plugin.PluginAgentClient",
return_value=mock_manager,
)
mocker.patch(
"core.agent.strategy.plugin.convert_parameters_to_plugin_format",
return_value={},
)
# Patch PluginInvokeContext to bypass pydantic validation
mock_context = MagicMock()
mocker.patch(
"core.agent.strategy.plugin.PluginInvokeContext",
return_value=mock_context,
)
credentials = MagicMock()
result = list(
strategy._invoke(
params={},
user_id="user_1",
credentials=credentials,
)
)
assert result == []
mock_manager.invoke.assert_called_once()
@pytest.mark.parametrize(
("conversation_id", "app_id", "message_id"),
[
(None, None, None),
("conv", None, None),
(None, "app", None),
(None, None, "msg"),
],
)
def test_invoke_optional_arguments(self, strategy, mocker, conversation_id, app_id, message_id) -> None:
mock_manager = MagicMock()
mock_manager.invoke = MagicMock(return_value=iter([]))
mocker.patch(
"core.agent.strategy.plugin.PluginAgentClient",
return_value=mock_manager,
)
mocker.patch(
"core.agent.strategy.plugin.convert_parameters_to_plugin_format",
return_value={},
)
result = list(
strategy._invoke(
params={},
user_id="user_1",
conversation_id=conversation_id,
app_id=app_id,
message_id=message_id,
)
)
assert result == []
mock_manager.invoke.assert_called_once()
def test_invoke_convert_raises_exception(self, strategy, mocker) -> None:
mocker.patch(
"core.agent.strategy.plugin.PluginAgentClient",
return_value=MagicMock(),
)
mocker.patch(
"core.agent.strategy.plugin.convert_parameters_to_plugin_format",
side_effect=ValueError("conversion failed"),
)
with pytest.raises(ValueError):
list(strategy._invoke(params={}, user_id="user_1"))
def test_invoke_manager_raises_exception(self, strategy, mocker) -> None:
mock_manager = MagicMock()
mock_manager.invoke.side_effect = RuntimeError("invoke failed")
mocker.patch(
"core.agent.strategy.plugin.PluginAgentClient",
return_value=mock_manager,
)
mocker.patch(
"core.agent.strategy.plugin.convert_parameters_to_plugin_format",
return_value={},
)
with pytest.raises(RuntimeError):
list(strategy._invoke(params={}, user_id="user_1"))

View File

@@ -0,0 +1,802 @@
import json
from decimal import Decimal
from unittest.mock import MagicMock
import pytest
import core.agent.base_agent_runner as module
from core.agent.base_agent_runner import BaseAgentRunner
# ==========================================================
# Fixtures
# ==========================================================
@pytest.fixture
def mock_db_session(mocker):
session = mocker.MagicMock()
mocker.patch.object(module.db, "session", session)
return session
@pytest.fixture
def runner(mocker, mock_db_session):
r = BaseAgentRunner.__new__(BaseAgentRunner)
r.tenant_id = "tenant"
r.user_id = "user"
r.agent_thought_count = 0
r.message = mocker.MagicMock(id="msg_current", conversation_id="conv1")
r.app_config = mocker.MagicMock()
r.app_config.app_id = "app1"
r.app_config.agent = None
r.dataset_tools = []
r.application_generate_entity = mocker.MagicMock(invoke_from="test")
r._current_thoughts = []
return r
# ==========================================================
# _repack_app_generate_entity
# ==========================================================
class TestRepack:
def test_sets_empty_if_none(self, runner, mocker):
entity = mocker.MagicMock()
entity.app_config.prompt_template.simple_prompt_template = None
result = runner._repack_app_generate_entity(entity)
assert result.app_config.prompt_template.simple_prompt_template == ""
def test_keeps_existing(self, runner, mocker):
entity = mocker.MagicMock()
entity.app_config.prompt_template.simple_prompt_template = "abc"
result = runner._repack_app_generate_entity(entity)
assert result.app_config.prompt_template.simple_prompt_template == "abc"
# ==========================================================
# update_prompt_message_tool
# ==========================================================
class TestUpdatePromptTool:
def build_param(self, mocker, **kwargs):
p = mocker.MagicMock()
p.form = kwargs.get("form")
mock_type = mocker.MagicMock()
mock_type.as_normal_type.return_value = "string"
p.type = mock_type
p.name = kwargs.get("name", "p1")
p.llm_description = "desc"
p.input_schema = kwargs.get("input_schema")
p.options = kwargs.get("options")
p.required = kwargs.get("required", False)
return p
def test_skip_non_llm(self, runner, mocker):
tool = mocker.MagicMock()
param = self.build_param(mocker, form="NOT_LLM")
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"] == {}
def test_enum_and_required(self, runner, mocker):
option = mocker.MagicMock(value="opt1")
param = self.build_param(
mocker,
form=module.ToolParameter.ToolParameterForm.LLM,
options=[option],
required=True,
)
tool = mocker.MagicMock()
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert "p1" in result.parameters["required"]
def test_skip_file_type_param(self, runner, mocker):
tool = mocker.MagicMock()
param = self.build_param(mocker, form=module.ToolParameter.ToolParameterForm.LLM)
param.type = module.ToolParameter.ToolParameterType.FILE
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"] == {}
def test_duplicate_required_not_duplicated(self, runner, mocker):
tool = mocker.MagicMock()
param = self.build_param(
mocker,
form=module.ToolParameter.ToolParameterForm.LLM,
required=True,
)
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": ["p1"]}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["required"].count("p1") == 1
# ==========================================================
# create_agent_thought
# ==========================================================
class TestCreateAgentThought:
def test_with_files(self, runner, mock_db_session, mocker):
mock_thought = mocker.MagicMock(id=10)
mocker.patch.object(module, "MessageAgentThought", return_value=mock_thought)
result = runner.create_agent_thought("m", "msg", "tool", "input", ["f1"])
assert result == "10"
assert runner.agent_thought_count == 1
def test_without_files(self, runner, mock_db_session, mocker):
mock_thought = mocker.MagicMock(id=11)
mocker.patch.object(module, "MessageAgentThought", return_value=mock_thought)
result = runner.create_agent_thought("m", "msg", "tool", "input", [])
assert result == "11"
# ==========================================================
# save_agent_thought
# ==========================================================
class TestSaveAgentThought:
def setup_agent(self, mocker):
agent = mocker.MagicMock()
agent.tool = "tool1;tool2"
agent.tool_labels = {}
agent.thought = ""
return agent
def test_not_found(self, runner, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(ValueError):
runner.save_agent_thought("id", None, None, None, None, None, None, [], None)
def test_full_update(self, runner, mock_db_session, mocker):
agent = self.setup_agent(mocker)
mock_db_session.scalar.return_value = agent
mock_label = mocker.MagicMock()
mock_label.to_dict.return_value = {"en_US": "label"}
mocker.patch.object(module.ToolManager, "get_tool_label", return_value=mock_label)
usage = mocker.MagicMock(
prompt_tokens=1,
prompt_price_unit=Decimal("0.1"),
prompt_unit_price=Decimal("0.1"),
completion_tokens=2,
completion_price_unit=Decimal("0.2"),
completion_unit_price=Decimal("0.2"),
total_tokens=3,
total_price=Decimal("0.3"),
)
runner.save_agent_thought(
"id",
"tool1;tool2",
{"a": 1},
"thought",
{"b": 2},
{"meta": 1},
"answer",
["f1"],
usage,
)
assert agent.answer == "answer"
assert agent.tokens == 3
assert "tool1" in json.loads(agent.tool_labels_str)
def test_label_fallback_when_none(self, runner, mock_db_session, mocker):
agent = self.setup_agent(mocker)
agent.tool = "unknown_tool"
mock_db_session.scalar.return_value = agent
mocker.patch.object(module.ToolManager, "get_tool_label", return_value=None)
runner.save_agent_thought("id", None, None, None, None, None, None, [], None)
labels = json.loads(agent.tool_labels_str)
assert "unknown_tool" in labels
def test_json_failure_paths(self, runner, mock_db_session, mocker):
agent = self.setup_agent(mocker)
mock_db_session.scalar.return_value = agent
bad_obj = MagicMock()
bad_obj.__str__.return_value = "bad"
runner.save_agent_thought(
"id",
None,
bad_obj,
None,
bad_obj,
bad_obj,
None,
[],
None,
)
assert mock_db_session.commit.called
def test_messages_ids_none(self, runner, mock_db_session, mocker):
agent = self.setup_agent(mocker)
mock_db_session.scalar.return_value = agent
runner.save_agent_thought("id", None, None, None, None, None, None, None, None)
assert mock_db_session.commit.called
def test_success_dict_serialization(self, runner, mock_db_session, mocker):
agent = self.setup_agent(mocker)
mock_db_session.scalar.return_value = agent
runner.save_agent_thought(
"id",
None,
{"a": 1},
None,
{"b": 2},
None,
None,
[],
None,
)
assert isinstance(agent.tool_input, str)
assert isinstance(agent.observation, str)
# ==========================================================
# organize_agent_user_prompt
# ==========================================================
class TestOrganizeUserPrompt:
def test_no_files(self, runner, mock_db_session, mocker):
mock_db_session.scalars.return_value.all.return_value = []
msg = mocker.MagicMock(id="1", query="hello", app_model_config=None)
result = runner.organize_agent_user_prompt(msg)
assert result.content == "hello"
def test_with_files_no_config(self, runner, mock_db_session, mocker):
mock_db_session.scalars.return_value.all.return_value = [mocker.MagicMock()]
msg = mocker.MagicMock(id="1", query="hello", app_model_config=None)
result = runner.organize_agent_user_prompt(msg)
assert result.content == "hello"
def test_image_detail_low_fallback(self, runner, mock_db_session, mocker):
mock_db_session.scalars.return_value.all.return_value = [mocker.MagicMock()]
file_config = mocker.MagicMock()
file_config.image_config = mocker.MagicMock(detail=None)
mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=file_config)
mocker.patch.object(module.file_factory, "build_from_message_files", return_value=[])
msg = mocker.MagicMock(id="1", query="hello")
msg.app_model_config.to_dict.return_value = {}
result = runner.organize_agent_user_prompt(msg)
assert result.content == "hello"
# ==========================================================
# organize_agent_history
# ==========================================================
class TestOrganizeHistory:
def test_empty(self, runner, mock_db_session, mocker):
mock_db_session.execute.return_value.scalars.return_value.all.return_value = []
mocker.patch.object(module, "extract_thread_messages", return_value=[])
result = runner.organize_agent_history([])
assert result == []
def test_with_answer_only(self, runner, mock_db_session, mocker):
msg = mocker.MagicMock(id="m1", answer="ans", agent_thoughts=[], app_model_config=None)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
result = runner.organize_agent_history([])
assert any(isinstance(x, module.AssistantPromptMessage) for x in result)
def test_skip_current_message(self, runner, mock_db_session, mocker):
msg = mocker.MagicMock(id="msg_current", agent_thoughts=[], answer="ans", app_model_config=None)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
result = runner.organize_agent_history([])
assert result == []
def test_with_tool_calls_invalid_json(self, runner, mock_db_session, mocker):
thought = mocker.MagicMock(
tool="tool1",
tool_input="invalid",
observation="invalid",
thought="thinking",
)
msg = mocker.MagicMock(id="m2", agent_thoughts=[thought], answer=None, app_model_config=None)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
mocker.patch("uuid.uuid4", return_value="uuid")
result = runner.organize_agent_history([])
assert isinstance(result, list)
def test_empty_tool_name_split(self, runner, mock_db_session, mocker):
thought = mocker.MagicMock(tool=";", thought="thinking")
msg = mocker.MagicMock(id="m5", agent_thoughts=[thought], answer=None, app_model_config=None)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
result = runner.organize_agent_history([])
assert isinstance(result, list)
def test_valid_json_tool_flow(self, runner, mock_db_session, mocker):
thought = mocker.MagicMock(
tool="tool1",
tool_input=json.dumps({"tool1": {"x": 1}}),
observation=json.dumps({"tool1": "obs"}),
thought="thinking",
)
msg = mocker.MagicMock(
id="m100",
agent_thoughts=[thought],
answer=None,
app_model_config=None,
)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
mocker.patch("uuid.uuid4", return_value="uuid")
result = runner.organize_agent_history([])
assert isinstance(result, list)
# ==========================================================
# _convert_tool_to_prompt_message_tool (new coverage)
# ==========================================================
class TestConvertToolToPromptMessageTool:
def test_basic_conversion(self, runner, mocker):
tool = mocker.MagicMock(tool_name="tool1")
runtime_param = mocker.MagicMock()
runtime_param.form = module.ToolParameter.ToolParameterForm.LLM
runtime_param.name = "param1"
runtime_param.llm_description = "desc"
runtime_param.required = True
runtime_param.input_schema = None
runtime_param.options = None
mock_type = mocker.MagicMock()
mock_type.as_normal_type.return_value = "string"
runtime_param.type = mock_type
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [runtime_param]
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool)
assert entity == tool_entity
def test_full_conversion_multiple_params(self, runner, mocker):
tool = mocker.MagicMock(tool_name="tool1")
# LLM param with input_schema override
param1 = mocker.MagicMock()
param1.form = module.ToolParameter.ToolParameterForm.LLM
param1.name = "p1"
param1.llm_description = "desc"
param1.required = True
param1.input_schema = {"type": "integer"}
param1.options = None
param1.type = mocker.MagicMock()
# SYSTEM_FILES param should be skipped
param2 = mocker.MagicMock()
param2.form = module.ToolParameter.ToolParameterForm.LLM
param2.name = "file_param"
param2.type = module.ToolParameter.ToolParameterType.SYSTEM_FILES
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [param1, param2]
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool)
assert entity == tool_entity
# ==========================================================
# _init_prompt_tools additional branches
# ==========================================================
class TestInitPromptToolsExtended:
def test_agent_tool_branch(self, runner, mocker):
agent_tool = mocker.MagicMock(tool_name="agent_tool")
runner.app_config.agent = mocker.MagicMock(tools=[agent_tool])
mocker.patch.object(runner, "_convert_tool_to_prompt_message_tool", return_value=(MagicMock(), "entity"))
tools, prompts = runner._init_prompt_tools()
assert "agent_tool" in tools
def test_exception_in_conversion(self, runner, mocker):
agent_tool = mocker.MagicMock(tool_name="bad_tool")
runner.app_config.agent = mocker.MagicMock(tools=[agent_tool])
mocker.patch.object(runner, "_convert_tool_to_prompt_message_tool", side_effect=Exception)
tools, prompts = runner._init_prompt_tools()
assert tools == {}
# ==========================================================
# Additional Coverage Tests (DO NOT MODIFY EXISTING TESTS)
# ==========================================================
class TestAdditionalCoverage:
def test_update_prompt_with_input_schema(self, runner, mocker):
tool = mocker.MagicMock()
param = mocker.MagicMock()
param.form = module.ToolParameter.ToolParameterForm.LLM
param.name = "p1"
param.required = False
param.llm_description = "desc"
param.options = None
param.input_schema = {"type": "number"}
mock_type = mocker.MagicMock()
mock_type.as_normal_type.return_value = "string"
param.type = mock_type
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"]["p1"]["type"] == "number"
def test_save_agent_thought_existing_labels(self, runner, mock_db_session, mocker):
agent = mocker.MagicMock()
agent.tool = "tool1"
agent.tool_labels = {"tool1": {"en_US": "existing"}}
agent.thought = ""
mock_db_session.scalar.return_value = agent
runner.save_agent_thought("id", None, None, None, None, None, None, [], None)
labels = json.loads(agent.tool_labels_str)
assert labels["tool1"]["en_US"] == "existing"
def test_save_agent_thought_tool_meta_string(self, runner, mock_db_session, mocker):
agent = mocker.MagicMock()
agent.tool = "tool1"
agent.tool_labels = {}
agent.thought = ""
mock_db_session.scalar.return_value = agent
runner.save_agent_thought("id", None, None, None, None, "meta_string", None, [], None)
assert agent.tool_meta_str == "meta_string"
def test_convert_dataset_retriever_tool(self, runner, mocker):
ds_tool = mocker.MagicMock()
ds_tool.entity.identity.name = "ds"
ds_tool.entity.description.llm = "desc"
param = mocker.MagicMock()
param.name = "query"
param.llm_description = "desc"
param.required = True
ds_tool.get_runtime_parameters.return_value = [param]
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt = runner._convert_dataset_retriever_tool_to_prompt_message_tool(ds_tool)
assert prompt is not None
def test_organize_user_prompt_with_file_objects(self, runner, mock_db_session, mocker):
mock_db_session.scalars.return_value.all.return_value = [mocker.MagicMock()]
file_config = mocker.MagicMock()
file_config.image_config = mocker.MagicMock(detail=None)
mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=file_config)
mocker.patch.object(module.file_factory, "build_from_message_files", return_value=["file1"])
mocker.patch.object(module.file_manager, "to_prompt_message_content", return_value=mocker.MagicMock())
mocker.patch.object(module, "UserPromptMessage", side_effect=lambda **kw: MagicMock(**kw))
mocker.patch.object(module, "TextPromptMessageContent", side_effect=lambda **kw: MagicMock(**kw))
msg = mocker.MagicMock(id="1", query="hello")
msg.app_model_config.to_dict.return_value = {}
result = runner.organize_agent_user_prompt(msg)
assert result is not None
def test_organize_history_without_tool_names(self, runner, mock_db_session, mocker):
thought = mocker.MagicMock(tool=None, thought="thinking")
msg = mocker.MagicMock(id="m3", agent_thoughts=[thought], answer=None, app_model_config=None)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
result = runner.organize_agent_history([])
assert isinstance(result, list)
def test_organize_history_multiple_tools_split(self, runner, mock_db_session, mocker):
thought = mocker.MagicMock(
tool="tool1;tool2",
tool_input=json.dumps({"tool1": {}, "tool2": {}}),
observation=json.dumps({"tool1": "o1", "tool2": "o2"}),
thought="thinking",
)
msg = mocker.MagicMock(id="m4", agent_thoughts=[thought], answer=None, app_model_config=None)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
mocker.patch("uuid.uuid4", return_value="uuid")
result = runner.organize_agent_history([])
assert isinstance(result, list)
# ================= Additional Surgical Coverage =================
def test_convert_tool_select_enum_branch(self, runner, mocker):
tool = mocker.MagicMock(tool_name="tool1")
param = mocker.MagicMock()
param.form = module.ToolParameter.ToolParameterForm.LLM
param.name = "select_param"
param.required = True
param.llm_description = "desc"
param.input_schema = None
option1 = mocker.MagicMock(value="A")
option2 = mocker.MagicMock(value="B")
param.options = [option1, option2]
param.type = module.ToolParameter.ToolParameterType.SELECT
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [param]
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool)
assert prompt_tool is not None
class TestConvertDatasetRetrieverTool:
def test_required_param_added(self, runner, mocker):
ds_tool = mocker.MagicMock()
ds_tool.entity.identity.name = "ds"
ds_tool.entity.description.llm = "desc"
param = mocker.MagicMock()
param.name = "query"
param.llm_description = "desc"
param.required = True
ds_tool.get_runtime_parameters.return_value = [param]
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt = runner._convert_dataset_retriever_tool_to_prompt_message_tool(ds_tool)
assert prompt is not None
class TestBaseAgentRunnerInit:
def test_init_sets_stream_tool_call_and_files(self, mocker):
session = mocker.MagicMock()
session.query.return_value.where.return_value.count.return_value = 2
mocker.patch.object(module.db, "session", session)
mocker.patch.object(BaseAgentRunner, "organize_agent_history", return_value=[])
mocker.patch.object(module.DatasetRetrieverTool, "get_dataset_tools", return_value=["ds_tool"])
llm = mocker.MagicMock()
llm.get_model_schema.return_value = mocker.MagicMock(
features=[module.ModelFeature.STREAM_TOOL_CALL, module.ModelFeature.VISION]
)
model_instance = mocker.MagicMock(model_type_instance=llm, model="m", credentials="c")
app_config = mocker.MagicMock()
app_config.app_id = "app1"
app_config.agent = None
app_config.dataset = mocker.MagicMock(dataset_ids=["d1"], retrieve_config={"k": "v"})
app_config.additional_features = mocker.MagicMock(show_retrieve_source=True)
app_generate = mocker.MagicMock(invoke_from="test", inputs={}, files=["file1"])
message = mocker.MagicMock(id="msg1", conversation_id="conv1")
runner = BaseAgentRunner(
tenant_id="tenant",
application_generate_entity=app_generate,
conversation=mocker.MagicMock(),
app_config=app_config,
model_config=mocker.MagicMock(),
config=mocker.MagicMock(),
queue_manager=mocker.MagicMock(),
message=message,
user_id="user",
model_instance=model_instance,
)
assert runner.stream_tool_call is True
assert runner.files == ["file1"]
assert runner.dataset_tools == ["ds_tool"]
assert runner.agent_thought_count == 2
class TestBaseAgentRunnerCoverage:
def test_convert_tool_skips_non_llm_param(self, runner, mocker):
tool = mocker.MagicMock(tool_name="tool1")
param = mocker.MagicMock()
param.form = "NOT_LLM"
param.type = mocker.MagicMock()
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [param]
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool)
assert prompt_tool.parameters["properties"] == {}
def test_init_prompt_tools_adds_dataset_tools(self, runner, mocker):
dataset_tool = mocker.MagicMock()
dataset_tool.entity.identity.name = "ds"
runner.dataset_tools = [dataset_tool]
mocker.patch.object(runner, "_convert_dataset_retriever_tool_to_prompt_message_tool", return_value=MagicMock())
tools, prompt_tools = runner._init_prompt_tools()
assert tools["ds"] == dataset_tool
assert len(prompt_tools) == 1
def test_update_prompt_message_tool_select_enum(self, runner, mocker):
tool = mocker.MagicMock()
option1 = mocker.MagicMock(value="A")
option2 = mocker.MagicMock(value="B")
param = mocker.MagicMock()
param.form = module.ToolParameter.ToolParameterForm.LLM
param.name = "select_param"
param.required = False
param.llm_description = "desc"
param.input_schema = None
param.options = [option1, option2]
param.type = module.ToolParameter.ToolParameterType.SELECT
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"]["select_param"]["enum"] == ["A", "B"]
def test_save_agent_thought_json_dumps_fallbacks(self, runner, mock_db_session, mocker):
agent = mocker.MagicMock()
agent.tool = "tool1"
agent.tool_labels = {}
agent.thought = ""
mock_db_session.scalar.return_value = agent
mocker.patch.object(module.ToolManager, "get_tool_label", return_value=None)
tool_input = {"a": 1}
observation = {"b": 2}
tool_meta = {"c": 3}
real_dumps = json.dumps
def dumps_side_effect(value, *args, **kwargs):
if value in (tool_input, observation, tool_meta) and kwargs.get("ensure_ascii") is False:
raise TypeError("fail")
return real_dumps(value, *args, **kwargs)
mocker.patch.object(module.json, "dumps", side_effect=dumps_side_effect)
runner.save_agent_thought(
"id",
"tool1",
tool_input,
None,
observation,
tool_meta,
None,
[],
None,
)
assert isinstance(agent.tool_input, str)
assert isinstance(agent.observation, str)
assert isinstance(agent.tool_meta_str, str)
def test_save_agent_thought_skips_empty_tool_name(self, runner, mock_db_session, mocker):
agent = mocker.MagicMock()
agent.tool = "tool1;;"
agent.tool_labels = {}
agent.thought = ""
mock_db_session.scalar.return_value = agent
mocker.patch.object(module.ToolManager, "get_tool_label", return_value=None)
runner.save_agent_thought("id", None, None, None, None, None, None, [], None)
labels = json.loads(agent.tool_labels_str)
assert "" not in labels
def test_organize_history_includes_system_prompt(self, runner, mock_db_session, mocker):
mock_db_session.execute.return_value.scalars.return_value.all.return_value = []
mocker.patch.object(module, "extract_thread_messages", return_value=[])
system_message = module.SystemPromptMessage(content="sys")
result = runner.organize_agent_history([system_message])
assert system_message in result
def test_organize_history_tool_inputs_and_observation_none(self, runner, mock_db_session, mocker):
thought = mocker.MagicMock(
tool="tool1",
tool_input=None,
observation=None,
thought="thinking",
)
msg = mocker.MagicMock(id="m6", agent_thoughts=[thought], answer=None, app_model_config=None)
mock_db_session.execute.return_value.scalars.return_value.all.return_value = [msg]
mocker.patch.object(module, "extract_thread_messages", return_value=[msg])
mocker.patch("uuid.uuid4", return_value="uuid")
mocker.patch.object(
runner,
"organize_agent_user_prompt",
return_value=module.UserPromptMessage(content="user"),
)
result = runner.organize_agent_history([])
assert any(isinstance(item, module.ToolPromptMessage) for item in result)

View File

@@ -0,0 +1,551 @@
import json
from unittest.mock import MagicMock
import pytest
from core.agent.cot_agent_runner import CotAgentRunner
from core.agent.entities import AgentScratchpadUnit
from dify_graph.model_runtime.entities.llm_entities import LLMUsage
from dify_graph.nodes.agent.exc import AgentMaxIterationError
class DummyRunner(CotAgentRunner):
"""Concrete implementation for testing abstract methods."""
def __init__(self, **kwargs):
# Completely bypass BaseAgentRunner __init__ to avoid DB/session usage
for k, v in kwargs.items():
setattr(self, k, v)
# Minimal required defaults
self.history_prompt_messages = []
self.memory = None
def _organize_prompt_messages(self):
return []
@pytest.fixture
def runner(mocker):
# Prevent BaseAgentRunner __init__ from hitting database
mocker.patch(
"core.agent.base_agent_runner.BaseAgentRunner.organize_agent_history",
return_value=[],
)
# Prepare required constructor dependencies for BaseAgentRunner
application_generate_entity = MagicMock()
application_generate_entity.model_conf = MagicMock()
application_generate_entity.model_conf.stop = []
application_generate_entity.model_conf.provider = "openai"
application_generate_entity.model_conf.parameters = {}
application_generate_entity.trace_manager = None
application_generate_entity.invoke_from = "test"
app_config = MagicMock()
app_config.agent = MagicMock()
app_config.agent.max_iteration = 1
app_config.prompt_template.simple_prompt_template = "Hello {{name}}"
model_instance = MagicMock()
model_instance.model = "test-model"
model_instance.model_name = "test-model"
model_instance.invoke_llm.return_value = []
model_config = MagicMock()
model_config.model = "test-model"
queue_manager = MagicMock()
message = MagicMock()
runner = DummyRunner(
tenant_id="tenant",
application_generate_entity=application_generate_entity,
conversation=MagicMock(),
app_config=app_config,
model_config=model_config,
config=MagicMock(),
queue_manager=queue_manager,
message=message,
user_id="user",
model_instance=model_instance,
)
# Patch internal methods to isolate behavior
runner._repack_app_generate_entity = MagicMock()
runner._init_prompt_tools = MagicMock(return_value=({}, []))
runner.recalc_llm_max_tokens = MagicMock()
runner.create_agent_thought = MagicMock(return_value="thought-id")
runner.save_agent_thought = MagicMock()
runner.update_prompt_message_tool = MagicMock()
runner.agent_callback = None
runner.memory = None
runner.history_prompt_messages = []
return runner
class TestFillInputs:
@pytest.mark.parametrize(
("instruction", "inputs", "expected"),
[
("Hello {{name}}", {"name": "John"}, "Hello John"),
("No placeholders", {"name": "John"}, "No placeholders"),
("{{a}}{{b}}", {"a": 1, "b": 2}, "12"),
("{{x}}", {"x": None}, "None"),
("", {"x": "y"}, ""),
],
)
def test_fill_in_inputs(self, runner, instruction, inputs, expected):
result = runner._fill_in_inputs_from_external_data_tools(instruction, inputs)
assert result == expected
class TestConvertDictToAction:
def test_convert_valid_dict(self, runner):
action_dict = {"action": "test", "action_input": {"a": 1}}
action = runner._convert_dict_to_action(action_dict)
assert action.action_name == "test"
assert action.action_input == {"a": 1}
def test_convert_missing_keys(self, runner):
with pytest.raises(KeyError):
runner._convert_dict_to_action({"invalid": 1})
class TestFormatAssistantMessage:
def test_format_assistant_message_multiple_scratchpads(self, runner):
sp1 = AgentScratchpadUnit(
agent_response="resp1",
thought="thought1",
action_str="action1",
action=AgentScratchpadUnit.Action(action_name="tool", action_input={}),
observation="obs1",
)
sp2 = AgentScratchpadUnit(
agent_response="final",
thought="",
action_str="",
action=AgentScratchpadUnit.Action(action_name="Final Answer", action_input="done"),
observation=None,
)
result = runner._format_assistant_message([sp1, sp2])
assert "Final Answer:" in result
def test_format_with_final(self, runner):
scratchpad = AgentScratchpadUnit(
agent_response="Done",
thought="",
action_str="",
action=None,
observation=None,
)
# Simulate final state via action name
scratchpad.action = AgentScratchpadUnit.Action(action_name="Final Answer", action_input="Done")
result = runner._format_assistant_message([scratchpad])
assert "Final Answer" in result
def test_format_with_action_and_observation(self, runner):
scratchpad = AgentScratchpadUnit(
agent_response="resp",
thought="thinking",
action_str="action",
action=None,
observation="obs",
)
# Non-final state: provide a non-final action
scratchpad.action = AgentScratchpadUnit.Action(action_name="tool", action_input={})
result = runner._format_assistant_message([scratchpad])
assert "Thought:" in result
assert "Action:" in result
assert "Observation:" in result
class TestHandleInvokeAction:
def test_handle_invoke_action_tool_not_present(self, runner):
action = AgentScratchpadUnit.Action(action_name="missing", action_input={})
response, meta = runner._handle_invoke_action(action, {}, [])
assert "there is not a tool named" in response
def test_tool_with_json_string_args(self, runner, mocker):
action = AgentScratchpadUnit.Action(action_name="tool", action_input=json.dumps({"a": 1}))
tool_instance = MagicMock()
tool_instances = {"tool": tool_instance}
mocker.patch(
"core.agent.cot_agent_runner.ToolEngine.agent_invoke",
return_value=("result", [], MagicMock(to_dict=lambda: {})),
)
response, meta = runner._handle_invoke_action(action, tool_instances, [])
assert response == "result"
class TestOrganizeHistoricPromptMessages:
def test_empty_history(self, runner, mocker):
mocker.patch(
"core.agent.cot_agent_runner.AgentHistoryPromptTransform.get_prompt",
return_value=[],
)
result = runner._organize_historic_prompt_messages([])
assert result == []
class TestRun:
def test_run_handles_empty_parser_output(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[],
)
results = list(runner.run(message, "query", {}))
assert isinstance(results, list)
def test_run_with_action_and_tool_invocation(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
action = AgentScratchpadUnit.Action(action_name="tool", action_input={})
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[action],
)
mocker.patch(
"core.agent.cot_agent_runner.ToolEngine.agent_invoke",
return_value=("ok", [], MagicMock(to_dict=lambda: {})),
)
runner.agent_callback = None
with pytest.raises(AgentMaxIterationError):
list(runner.run(message, "query", {"tool": MagicMock()}))
def test_run_respects_max_iteration_boundary(self, runner, mocker):
runner.app_config.agent.max_iteration = 1
message = MagicMock()
message.id = "msg-id"
action = AgentScratchpadUnit.Action(action_name="tool", action_input={})
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[action],
)
mocker.patch(
"core.agent.cot_agent_runner.ToolEngine.agent_invoke",
return_value=("ok", [], MagicMock(to_dict=lambda: {})),
)
runner.agent_callback = None
with pytest.raises(AgentMaxIterationError):
list(runner.run(message, "query", {"tool": MagicMock()}))
def test_run_basic_flow(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[],
)
results = list(runner.run(message, "query", {"name": "John"}))
assert results
def test_run_max_iteration_error(self, runner, mocker):
runner.app_config.agent.max_iteration = 0
message = MagicMock()
message.id = "msg-id"
action = AgentScratchpadUnit.Action(action_name="tool", action_input={})
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[action],
)
with pytest.raises(AgentMaxIterationError):
list(runner.run(message, "query", {}))
def test_run_increase_usage_aggregation(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
runner.app_config.agent.max_iteration = 2
usage_1 = LLMUsage.empty_usage()
usage_1.prompt_tokens = 1
usage_1.completion_tokens = 1
usage_1.total_tokens = 2
usage_1.prompt_price = 1
usage_1.completion_price = 1
usage_1.total_price = 2
usage_2 = LLMUsage.empty_usage()
usage_2.prompt_tokens = 1
usage_2.completion_tokens = 1
usage_2.total_tokens = 2
usage_2.prompt_price = 1
usage_2.completion_price = 1
usage_2.total_price = 2
action = AgentScratchpadUnit.Action(action_name="tool", action_input={})
handle_output = mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
side_effect=[
[action],
[],
],
)
def _handle_side_effect(chunks, usage_dict):
call_index = handle_output.call_count
usage_dict["usage"] = usage_1 if call_index == 1 else usage_2
return [action] if call_index == 1 else []
handle_output.side_effect = _handle_side_effect
runner.model_instance.invoke_llm = MagicMock(return_value=[])
mocker.patch(
"core.agent.cot_agent_runner.ToolEngine.agent_invoke",
return_value=("ok", [], MagicMock(to_dict=lambda: {})),
)
fake_prompt_tool = MagicMock()
fake_prompt_tool.name = "tool"
runner._init_prompt_tools = MagicMock(return_value=({"tool": MagicMock()}, [fake_prompt_tool]))
results = list(runner.run(message, "query", {}))
final_usage = results[-1].delta.usage
assert final_usage is not None
assert final_usage.prompt_tokens == 2
assert final_usage.completion_tokens == 2
assert final_usage.total_tokens == 4
assert final_usage.prompt_price == 2
assert final_usage.completion_price == 2
assert final_usage.total_price == 4
def test_run_when_no_action_branch(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[],
)
results = list(runner.run(message, "query", {}))
assert results[-1].delta.message.content == ""
def test_run_usage_missing_key_branch(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[],
)
runner.model_instance.invoke_llm = MagicMock(return_value=[])
list(runner.run(message, "query", {}))
def test_run_prompt_tool_update_branch(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
action = AgentScratchpadUnit.Action(action_name="tool", action_input={})
# First iteration → action
# Second iteration → no action (empty list)
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
side_effect=[[action], []],
)
mocker.patch(
"core.agent.cot_agent_runner.ToolEngine.agent_invoke",
return_value=("ok", [], MagicMock(to_dict=lambda: {})),
)
runner.app_config.agent.max_iteration = 5
fake_prompt_tool = MagicMock()
fake_prompt_tool.name = "tool"
runner._init_prompt_tools = MagicMock(return_value=({"tool": MagicMock()}, [fake_prompt_tool]))
runner.update_prompt_message_tool = MagicMock()
runner.agent_callback = None
list(runner.run(message, "query", {}))
runner.update_prompt_message_tool.assert_called_once()
def test_historic_with_assistant_and_tool_calls(self, runner):
from dify_graph.model_runtime.entities.message_entities import AssistantPromptMessage, ToolPromptMessage
assistant = AssistantPromptMessage(content="thinking")
assistant.tool_calls = [MagicMock(function=MagicMock(name="tool", arguments='{"a":1}'))]
tool_msg = ToolPromptMessage(content="obs", tool_call_id="1")
runner.history_prompt_messages = [assistant, tool_msg]
result = runner._organize_historic_prompt_messages([])
assert isinstance(result, list)
def test_historic_final_flush_branch(self, runner):
from dify_graph.model_runtime.entities.message_entities import AssistantPromptMessage
assistant = AssistantPromptMessage(content="final")
runner.history_prompt_messages = [assistant]
result = runner._organize_historic_prompt_messages([])
assert isinstance(result, list)
class TestInitReactState:
def test_init_react_state_resets_state(self, runner, mocker):
mocker.patch.object(runner, "_organize_historic_prompt_messages", return_value=["historic"])
runner._agent_scratchpad = ["old"]
runner._query = "old"
runner._init_react_state("new-query")
assert runner._query == "new-query"
assert runner._agent_scratchpad == []
assert runner._historic_prompt_messages == ["historic"]
class TestHandleInvokeActionExtended:
def test_tool_with_invalid_json_string_args(self, runner, mocker):
action = AgentScratchpadUnit.Action(action_name="tool", action_input="not-json")
tool_instance = MagicMock()
tool_instances = {"tool": tool_instance}
mocker.patch(
"core.agent.cot_agent_runner.ToolEngine.agent_invoke",
return_value=("ok", ["file1"], MagicMock(to_dict=lambda: {"k": "v"})),
)
message_file_ids = []
response, meta = runner._handle_invoke_action(action, tool_instances, message_file_ids)
assert response == "ok"
assert message_file_ids == ["file1"]
runner.queue_manager.publish.assert_called()
class TestFillInputsEdgeCases:
def test_fill_inputs_with_empty_inputs(self, runner):
result = runner._fill_in_inputs_from_external_data_tools("Hello {{x}}", {})
assert result == "Hello {{x}}"
def test_fill_inputs_with_exception_in_replace(self, runner):
class BadValue:
def __str__(self):
raise Exception("fail")
# Should silently continue on exception
result = runner._fill_in_inputs_from_external_data_tools("Hello {{x}}", {"x": BadValue()})
assert result == "Hello {{x}}"
class TestOrganizeHistoricPromptMessagesExtended:
def test_user_message_flushes_scratchpad(self, runner, mocker):
from dify_graph.model_runtime.entities.message_entities import UserPromptMessage
user_message = UserPromptMessage(content="Hi")
runner.history_prompt_messages = [user_message]
mock_transform = mocker.patch(
"core.agent.cot_agent_runner.AgentHistoryPromptTransform",
)
mock_transform.return_value.get_prompt.return_value = ["final"]
result = runner._organize_historic_prompt_messages([])
assert result == ["final"]
def test_tool_message_without_scratchpad_raises(self, runner):
from dify_graph.model_runtime.entities.message_entities import ToolPromptMessage
runner.history_prompt_messages = [ToolPromptMessage(content="obs", tool_call_id="1")]
with pytest.raises(NotImplementedError):
runner._organize_historic_prompt_messages([])
def test_agent_history_transform_invocation(self, runner, mocker):
mock_transform = MagicMock()
mock_transform.get_prompt.return_value = []
mocker.patch(
"core.agent.cot_agent_runner.AgentHistoryPromptTransform",
return_value=mock_transform,
)
runner.history_prompt_messages = []
result = runner._organize_historic_prompt_messages([])
assert result == []
class TestRunAdditionalBranches:
def test_run_with_no_action_final_answer_empty(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=["thinking"],
)
results = list(runner.run(message, "query", {}))
assert any(hasattr(r, "delta") for r in results)
def test_run_with_final_answer_action_string(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
action = AgentScratchpadUnit.Action(action_name="Final Answer", action_input="done")
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[action],
)
results = list(runner.run(message, "query", {}))
assert results[-1].delta.message.content == "done"
def test_run_with_final_answer_action_dict(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
action = AgentScratchpadUnit.Action(action_name="Final Answer", action_input={"a": 1})
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[action],
)
results = list(runner.run(message, "query", {}))
assert json.loads(results[-1].delta.message.content) == {"a": 1}
def test_run_with_string_final_answer(self, runner, mocker):
message = MagicMock()
message.id = "msg-id"
# Remove invalid branch: Pydantic enforces str|dict for action_input
action = AgentScratchpadUnit.Action(action_name="Final Answer", action_input="12345")
mocker.patch(
"core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output",
return_value=[action],
)
results = list(runner.run(message, "query", {}))
assert results[-1].delta.message.content == "12345"

View File

@@ -0,0 +1,215 @@
from unittest.mock import MagicMock, patch
import pytest
from core.agent.cot_chat_agent_runner import CotChatAgentRunner
from dify_graph.model_runtime.entities.message_entities import TextPromptMessageContent
from tests.unit_tests.core.agent.conftest import (
DummyAgentConfig,
DummyAppConfig,
DummyTool,
)
from tests.unit_tests.core.agent.conftest import (
DummyPromptEntity as DummyPrompt,
)
class DummyFileUploadConfig:
def __init__(self, image_config=None):
self.image_config = image_config
class DummyImageConfig:
def __init__(self, detail=None):
self.detail = detail
class DummyGenerateEntity:
def __init__(self, file_upload_config=None):
self.file_upload_config = file_upload_config
class DummyUnit:
def __init__(self, final=False, thought=None, action_str=None, observation=None, agent_response=None):
self._final = final
self.thought = thought
self.action_str = action_str
self.observation = observation
self.agent_response = agent_response
def is_final(self):
return self._final
@pytest.fixture
def runner():
runner = CotChatAgentRunner.__new__(CotChatAgentRunner)
runner._instruction = "test_instruction"
runner._prompt_messages_tools = [DummyTool("tool1"), DummyTool("tool2")]
runner._query = "user query"
runner._agent_scratchpad = []
runner.files = []
runner.application_generate_entity = DummyGenerateEntity()
runner._organize_historic_prompt_messages = MagicMock(return_value=["historic"])
return runner
class TestOrganizeSystemPrompt:
def test_organize_system_prompt_success(self, runner, mocker):
first_prompt = "Instruction: {{instruction}}, Tools: {{tools}}, Names: {{tool_names}}"
runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt(first_prompt)))
mocker.patch(
"core.agent.cot_chat_agent_runner.jsonable_encoder",
return_value=[{"name": "tool1"}, {"name": "tool2"}],
)
result = runner._organize_system_prompt()
assert "test_instruction" in result.content
assert "tool1" in result.content
assert "tool2" in result.content
assert "tool1, tool2" in result.content
def test_organize_system_prompt_missing_agent(self, runner):
runner.app_config = DummyAppConfig(agent=None)
with pytest.raises(AssertionError):
runner._organize_system_prompt()
def test_organize_system_prompt_missing_prompt(self, runner):
runner.app_config = DummyAppConfig(DummyAgentConfig(prompt_entity=None))
with pytest.raises(AssertionError):
runner._organize_system_prompt()
class TestOrganizeUserQuery:
@pytest.mark.parametrize("files", [None, pytest.param([], id="empty_list")])
def test_organize_user_query_no_files(self, runner, files):
runner.files = files
result = runner._organize_user_query("query", [])
assert len(result) == 1
assert result[0].content == "query"
@patch("core.agent.cot_chat_agent_runner.UserPromptMessage")
@patch("core.agent.cot_chat_agent_runner.file_manager.to_prompt_message_content")
def test_organize_user_query_with_image_file_default_config(self, mock_to_prompt, mock_user_prompt, runner):
from dify_graph.model_runtime.entities.message_entities import ImagePromptMessageContent
mock_content = ImagePromptMessageContent(
url="http://test",
format="png",
mime_type="image/png",
)
mock_to_prompt.return_value = mock_content
mock_user_prompt.side_effect = lambda content: MagicMock(content=content)
runner.files = ["file1"]
runner.application_generate_entity = DummyGenerateEntity(None)
result = runner._organize_user_query("query", [])
assert len(result) == 1
assert isinstance(result[0].content, list)
assert mock_content in result[0].content
mock_to_prompt.assert_called_once_with(
"file1",
image_detail_config=ImagePromptMessageContent.DETAIL.LOW,
)
@patch("core.agent.cot_chat_agent_runner.UserPromptMessage")
@patch("core.agent.cot_chat_agent_runner.file_manager.to_prompt_message_content")
def test_organize_user_query_with_image_file_high_detail(self, mock_to_prompt, mock_user_prompt, runner):
from dify_graph.model_runtime.entities.message_entities import ImagePromptMessageContent
mock_content = ImagePromptMessageContent(
url="http://test",
format="png",
mime_type="image/png",
)
mock_to_prompt.return_value = mock_content
mock_user_prompt.side_effect = lambda content: MagicMock(content=content)
runner.files = ["file1"]
image_config = DummyImageConfig(detail="high")
runner.application_generate_entity = DummyGenerateEntity(DummyFileUploadConfig(image_config))
result = runner._organize_user_query("query", [])
assert len(result) == 1
assert isinstance(result[0].content, list)
assert mock_content in result[0].content
mock_to_prompt.assert_called_once_with(
"file1",
image_detail_config=ImagePromptMessageContent.DETAIL.HIGH,
)
@patch("core.agent.cot_chat_agent_runner.file_manager.to_prompt_message_content")
def test_organize_user_query_with_text_file_no_config(self, mock_to_prompt, runner):
mock_to_prompt.return_value = TextPromptMessageContent(data="file_content")
runner.files = ["file1"]
runner.application_generate_entity = DummyGenerateEntity(None)
result = runner._organize_user_query("query", [])
assert len(result) == 1
assert isinstance(result[0].content, list)
class TestOrganizePromptMessages:
def test_no_scratchpad(self, runner, mocker):
runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}")))
runner._organize_system_prompt = MagicMock(return_value="system")
runner._organize_user_query = MagicMock(return_value=["query"])
result = runner._organize_prompt_messages()
assert "system" in result
assert "query" in result
runner._organize_historic_prompt_messages.assert_called_once()
def test_with_final_scratchpad(self, runner, mocker):
runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}")))
runner._organize_system_prompt = MagicMock(return_value="system")
runner._organize_user_query = MagicMock(return_value=["query"])
unit = DummyUnit(final=True, agent_response="done")
runner._agent_scratchpad = [unit]
result = runner._organize_prompt_messages()
assistant_msgs = [m for m in result if hasattr(m, "content")]
combined = "".join([m.content for m in assistant_msgs if isinstance(m.content, str)])
assert "Final Answer: done" in combined
def test_with_thought_action_observation(self, runner, mocker):
runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}")))
runner._organize_system_prompt = MagicMock(return_value="system")
runner._organize_user_query = MagicMock(return_value=["query"])
unit = DummyUnit(
final=False,
thought="thinking",
action_str="action",
observation="observe",
)
runner._agent_scratchpad = [unit]
result = runner._organize_prompt_messages()
assistant_msgs = [m for m in result if hasattr(m, "content")]
combined = "".join([m.content for m in assistant_msgs if isinstance(m.content, str)])
assert "Thought: thinking" in combined
assert "Action: action" in combined
assert "Observation: observe" in combined
def test_multiple_units_mixed(self, runner, mocker):
runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt("{{instruction}}")))
runner._organize_system_prompt = MagicMock(return_value="system")
runner._organize_user_query = MagicMock(return_value=["query"])
units = [
DummyUnit(final=False, thought="t1"),
DummyUnit(final=True, agent_response="done"),
]
runner._agent_scratchpad = units
result = runner._organize_prompt_messages()
assistant_msgs = [m for m in result if hasattr(m, "content")]
combined = "".join([m.content for m in assistant_msgs if isinstance(m.content, str)])
assert "Thought: t1" in combined
assert "Final Answer: done" in combined

View File

@@ -0,0 +1,234 @@
import json
import pytest
from core.agent.cot_completion_agent_runner import CotCompletionAgentRunner
from dify_graph.model_runtime.entities.message_entities import (
AssistantPromptMessage,
ImagePromptMessageContent,
TextPromptMessageContent,
UserPromptMessage,
)
# -----------------------------
# Fixtures
# -----------------------------
@pytest.fixture
def runner(mocker, dummy_tool_factory):
runner = CotCompletionAgentRunner.__new__(CotCompletionAgentRunner)
runner._instruction = "Test instruction"
runner._prompt_messages_tools = [dummy_tool_factory("toolA"), dummy_tool_factory("toolB")]
runner._query = "What is Python?"
runner._agent_scratchpad = []
mocker.patch(
"core.agent.cot_completion_agent_runner.jsonable_encoder",
side_effect=lambda tools: [{"name": t.name} for t in tools],
)
return runner
# ======================================================
# _organize_instruction_prompt Tests
# ======================================================
class TestOrganizeInstructionPrompt:
def test_success_all_placeholders(
self, runner, dummy_app_config_factory, dummy_agent_config_factory, dummy_prompt_entity_factory
):
template = (
"{{instruction}} | {{tools}} | {{tool_names}} | {{historic_messages}} | {{agent_scratchpad}} | {{query}}"
)
runner.app_config = dummy_app_config_factory(
agent=dummy_agent_config_factory(prompt_entity=dummy_prompt_entity_factory(template))
)
result = runner._organize_instruction_prompt()
assert "Test instruction" in result
assert "toolA" in result
assert "toolB" in result
tools_payload = json.loads(result.split(" | ")[1])
assert {item["name"] for item in tools_payload} == {"toolA", "toolB"}
def test_agent_none_raises(self, runner, dummy_app_config_factory):
runner.app_config = dummy_app_config_factory(agent=None)
with pytest.raises(ValueError, match="Agent configuration is not set"):
runner._organize_instruction_prompt()
def test_prompt_entity_none_raises(self, runner, dummy_app_config_factory, dummy_agent_config_factory):
runner.app_config = dummy_app_config_factory(agent=dummy_agent_config_factory(prompt_entity=None))
with pytest.raises(ValueError, match="prompt entity is not set"):
runner._organize_instruction_prompt()
# ======================================================
# _organize_historic_prompt Tests
# ======================================================
class TestOrganizeHistoricPrompt:
def test_with_user_and_assistant_string(self, runner, mocker):
user_msg = UserPromptMessage(content="Hello")
assistant_msg = AssistantPromptMessage(content="Hi there")
mocker.patch.object(
runner,
"_organize_historic_prompt_messages",
return_value=[user_msg, assistant_msg],
)
result = runner._organize_historic_prompt()
assert "Question: Hello" in result
assert "Hi there" in result
def test_assistant_list_with_text_content(self, runner, mocker):
text_content = TextPromptMessageContent(data="Partial answer")
assistant_msg = AssistantPromptMessage(content=[text_content])
mocker.patch.object(
runner,
"_organize_historic_prompt_messages",
return_value=[assistant_msg],
)
result = runner._organize_historic_prompt()
assert "Partial answer" in result
def test_assistant_list_with_non_text_content_ignored(self, runner, mocker):
non_text_content = ImagePromptMessageContent(format="url", mime_type="image/png")
assistant_msg = AssistantPromptMessage(content=[non_text_content])
mocker.patch.object(
runner,
"_organize_historic_prompt_messages",
return_value=[assistant_msg],
)
result = runner._organize_historic_prompt()
assert result == ""
def test_empty_history(self, runner, mocker):
mocker.patch.object(
runner,
"_organize_historic_prompt_messages",
return_value=[],
)
result = runner._organize_historic_prompt()
assert result == ""
# ======================================================
# _organize_prompt_messages Tests
# ======================================================
class TestOrganizePromptMessages:
def test_full_flow_with_scratchpad(
self,
runner,
mocker,
dummy_app_config_factory,
dummy_agent_config_factory,
dummy_prompt_entity_factory,
dummy_scratchpad_unit_factory,
):
template = "SYS {{historic_messages}} {{agent_scratchpad}} {{query}}"
runner.app_config = dummy_app_config_factory(
agent=dummy_agent_config_factory(prompt_entity=dummy_prompt_entity_factory(template))
)
mocker.patch.object(runner, "_organize_historic_prompt", return_value="History\n")
runner._agent_scratchpad = [
dummy_scratchpad_unit_factory(final=False, thought="Thinking", action_str="Act", observation="Obs"),
dummy_scratchpad_unit_factory(final=True, agent_response="Done"),
]
result = runner._organize_prompt_messages()
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], UserPromptMessage)
content = result[0].content
assert "History" in content
assert "Thought: Thinking" in content
assert "Action: Act" in content
assert "Observation: Obs" in content
assert "Final Answer: Done" in content
assert "Question: What is Python?" in content
def test_no_scratchpad(
self, runner, mocker, dummy_app_config_factory, dummy_agent_config_factory, dummy_prompt_entity_factory
):
template = "SYS {{historic_messages}} {{agent_scratchpad}} {{query}}"
runner.app_config = dummy_app_config_factory(
agent=dummy_agent_config_factory(prompt_entity=dummy_prompt_entity_factory(template))
)
mocker.patch.object(runner, "_organize_historic_prompt", return_value="")
runner._agent_scratchpad = None
result = runner._organize_prompt_messages()
assert "Question: What is Python?" in result[0].content
@pytest.mark.parametrize(
("thought", "action", "observation"),
[
("T", None, None),
("T", "A", None),
("T", None, "O"),
],
)
def test_partial_scratchpad_units(
self,
runner,
mocker,
thought,
action,
observation,
dummy_app_config_factory,
dummy_agent_config_factory,
dummy_prompt_entity_factory,
dummy_scratchpad_unit_factory,
):
template = "SYS {{historic_messages}} {{agent_scratchpad}} {{query}}"
runner.app_config = dummy_app_config_factory(
agent=dummy_agent_config_factory(prompt_entity=dummy_prompt_entity_factory(template))
)
mocker.patch.object(runner, "_organize_historic_prompt", return_value="")
runner._agent_scratchpad = [
dummy_scratchpad_unit_factory(
final=False,
thought=thought,
action_str=action,
observation=observation,
)
]
result = runner._organize_prompt_messages()
content = result[0].content
assert "Thought:" in content
if action:
assert "Action:" in content
if observation:
assert "Observation:" in content

View File

@@ -0,0 +1,452 @@
import json
from typing import Any
from unittest.mock import MagicMock
import pytest
from core.agent.fc_agent_runner import FunctionCallAgentRunner
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.entities.queue_entities import QueueMessageFileEvent
from dify_graph.model_runtime.entities.llm_entities import LLMUsage
from dify_graph.model_runtime.entities.message_entities import (
DocumentPromptMessageContent,
ImagePromptMessageContent,
TextPromptMessageContent,
UserPromptMessage,
)
from dify_graph.nodes.agent.exc import AgentMaxIterationError
# ==============================
# Dummy Helper Classes
# ==============================
def build_usage(pt=1, ct=1, tt=2) -> LLMUsage:
usage = LLMUsage.empty_usage()
usage.prompt_tokens = pt
usage.completion_tokens = ct
usage.total_tokens = tt
usage.prompt_price = 0
usage.completion_price = 0
usage.total_price = 0
return usage
class DummyMessage:
def __init__(self, content: str | None = None, tool_calls: list[Any] | None = None):
self.content: str | None = content
self.tool_calls: list[Any] = tool_calls or []
class DummyDelta:
def __init__(self, message: DummyMessage | None = None, usage: LLMUsage | None = None):
self.message: DummyMessage | None = message
self.usage: LLMUsage | None = usage
class DummyChunk:
def __init__(self, message: DummyMessage | None = None, usage: LLMUsage | None = None):
self.delta: DummyDelta = DummyDelta(message=message, usage=usage)
class DummyResult:
def __init__(
self,
message: DummyMessage | None = None,
usage: LLMUsage | None = None,
prompt_messages: list[DummyMessage] | None = None,
):
self.message: DummyMessage | None = message
self.usage: LLMUsage | None = usage
self.prompt_messages: list[DummyMessage] = prompt_messages or []
self.system_fingerprint: str = ""
# ==============================
# Fixtures
# ==============================
@pytest.fixture
def runner(mocker):
# Completely bypass BaseAgentRunner __init__ to avoid DB / Flask context
mocker.patch(
"core.agent.base_agent_runner.BaseAgentRunner.__init__",
return_value=None,
)
# Patch streaming chunk models to avoid validation on dummy message objects
mocker.patch("core.agent.fc_agent_runner.LLMResultChunk", MagicMock)
mocker.patch("core.agent.fc_agent_runner.LLMResultChunkDelta", MagicMock)
app_config = MagicMock()
app_config.agent = MagicMock(max_iteration=2)
app_config.prompt_template = MagicMock(simple_prompt_template="system")
application_generate_entity = MagicMock()
application_generate_entity.model_conf = MagicMock(parameters={}, stop=None)
application_generate_entity.trace_manager = MagicMock()
application_generate_entity.invoke_from = "test"
application_generate_entity.app_config = MagicMock(app_id="app")
application_generate_entity.file_upload_config = None
queue_manager = MagicMock()
model_instance = MagicMock()
model_instance.model = "test-model"
model_instance.model_name = "test-model"
message = MagicMock(id="msg1")
conversation = MagicMock(id="conv1")
runner = FunctionCallAgentRunner(
tenant_id="tenant",
application_generate_entity=application_generate_entity,
conversation=conversation,
app_config=app_config,
model_config=MagicMock(),
config=MagicMock(),
queue_manager=queue_manager,
message=message,
user_id="user",
model_instance=model_instance,
)
# Manually inject required attributes normally set by BaseAgentRunner
runner.tenant_id = "tenant"
runner.application_generate_entity = application_generate_entity
runner.conversation = conversation
runner.app_config = app_config
runner.model_config = MagicMock()
runner.config = MagicMock()
runner.queue_manager = queue_manager
runner.message = message
runner.user_id = "user"
runner.model_instance = model_instance
runner.stream_tool_call = False
runner.memory = None
runner.history_prompt_messages = []
runner._current_thoughts = []
runner.files = []
runner.agent_callback = MagicMock()
runner._init_prompt_tools = MagicMock(return_value=({}, []))
runner.create_agent_thought = MagicMock(return_value="thought1")
runner.save_agent_thought = MagicMock()
runner.recalc_llm_max_tokens = MagicMock()
runner.update_prompt_message_tool = MagicMock()
return runner
# ==============================
# Tool Call Checks
# ==============================
class TestToolCallChecks:
@pytest.mark.parametrize(("tool_calls", "expected"), [([], False), ([MagicMock()], True)])
def test_check_tool_calls(self, runner, tool_calls, expected):
chunk = DummyChunk(message=DummyMessage(tool_calls=tool_calls))
assert runner.check_tool_calls(chunk) is expected
@pytest.mark.parametrize(("tool_calls", "expected"), [([], False), ([MagicMock()], True)])
def test_check_blocking_tool_calls(self, runner, tool_calls, expected):
result = DummyResult(message=DummyMessage(tool_calls=tool_calls))
assert runner.check_blocking_tool_calls(result) is expected
# ==============================
# Extract Tool Calls
# ==============================
class TestExtractToolCalls:
def test_extract_tool_calls_with_valid_json(self, runner):
tool_call = MagicMock()
tool_call.id = "1"
tool_call.function.name = "tool"
tool_call.function.arguments = json.dumps({"a": 1})
chunk = DummyChunk(message=DummyMessage(tool_calls=[tool_call]))
calls = runner.extract_tool_calls(chunk)
assert calls == [("1", "tool", {"a": 1})]
def test_extract_tool_calls_empty_arguments(self, runner):
tool_call = MagicMock()
tool_call.id = "1"
tool_call.function.name = "tool"
tool_call.function.arguments = ""
chunk = DummyChunk(message=DummyMessage(tool_calls=[tool_call]))
calls = runner.extract_tool_calls(chunk)
assert calls == [("1", "tool", {})]
def test_extract_blocking_tool_calls(self, runner):
tool_call = MagicMock()
tool_call.id = "2"
tool_call.function.name = "block"
tool_call.function.arguments = json.dumps({"x": 2})
result = DummyResult(message=DummyMessage(tool_calls=[tool_call]))
calls = runner.extract_blocking_tool_calls(result)
assert calls == [("2", "block", {"x": 2})]
# ==============================
# System Message Initialization
# ==============================
class TestInitSystemMessage:
def test_init_system_message_empty_prompt_messages(self, runner):
result = runner._init_system_message("system", [])
assert len(result) == 1
def test_init_system_message_insert_at_start(self, runner):
msgs = [MagicMock()]
result = runner._init_system_message("system", msgs)
assert result[0].content == "system"
def test_init_system_message_no_template(self, runner):
result = runner._init_system_message("", [])
assert result == []
# ==============================
# Organize User Query
# ==============================
class TestOrganizeUserQuery:
def test_without_files(self, runner):
result = runner._organize_user_query("query", [])
assert len(result) == 1
def test_with_none_query(self, runner):
result = runner._organize_user_query(None, [])
assert len(result) == 1
def test_with_files_uses_image_detail_config(self, runner, mocker):
file_content = TextPromptMessageContent(data="file-content")
mock_to_prompt = mocker.patch(
"core.agent.fc_agent_runner.file_manager.to_prompt_message_content",
return_value=file_content,
)
image_config = MagicMock(detail=ImagePromptMessageContent.DETAIL.HIGH)
runner.application_generate_entity.file_upload_config = MagicMock(image_config=image_config)
runner.files = ["file1"]
result = runner._organize_user_query("query", [])
assert len(result) == 1
assert isinstance(result[0].content, list)
mock_to_prompt.assert_called_once_with("file1", image_detail_config=ImagePromptMessageContent.DETAIL.HIGH)
# ==============================
# Clear User Prompt Images
# ==============================
class TestClearUserPromptImageMessages:
def test_clear_text_and_image_content(self, runner):
text = MagicMock()
text.type = "text"
text.data = "hello"
image = MagicMock()
image.type = "image"
image.data = "img"
user_msg = MagicMock()
user_msg.__class__.__name__ = "UserPromptMessage"
user_msg.content = [text, image]
result = runner._clear_user_prompt_image_messages([user_msg])
assert isinstance(result, list)
def test_clear_includes_file_placeholder(self, runner):
text = TextPromptMessageContent(data="hello")
image = ImagePromptMessageContent(format="url", mime_type="image/png")
document = DocumentPromptMessageContent(format="url", mime_type="application/pdf")
user_msg = UserPromptMessage(content=[text, image, document])
result = runner._clear_user_prompt_image_messages([user_msg])
assert result[0].content == "hello\n[image]\n[file]"
# ==============================
# Run Method Tests
# ==============================
class TestRunMethod:
def test_run_non_streaming_no_tool_calls(self, runner):
message = MagicMock(id="m1")
dummy_message = DummyMessage(content="hello")
result = DummyResult(message=dummy_message, usage=build_usage())
runner.model_instance.invoke_llm.return_value = result
outputs = list(runner.run(message, "query"))
assert len(outputs) == 1
runner.queue_manager.publish.assert_called()
queue_calls = runner.queue_manager.publish.call_args_list
assert any(call.args and call.args[0].__class__.__name__ == "QueueMessageEndEvent" for call in queue_calls)
def test_run_streaming_branch(self, runner):
message = MagicMock(id="m1")
runner.stream_tool_call = True
content = [TextPromptMessageContent(data="hi")]
chunk = DummyChunk(message=DummyMessage(content=content), usage=build_usage())
def generator():
yield chunk
runner.model_instance.invoke_llm.return_value = generator()
outputs = list(runner.run(message, "query"))
assert len(outputs) == 1
def test_run_streaming_tool_calls_list_content(self, runner):
message = MagicMock(id="m1")
runner.stream_tool_call = True
tool_call = MagicMock()
tool_call.id = "1"
tool_call.function.name = "tool"
tool_call.function.arguments = json.dumps({"a": 1})
content = [TextPromptMessageContent(data="hi")]
chunk = DummyChunk(message=DummyMessage(content=content, tool_calls=[tool_call]), usage=build_usage())
def generator():
yield chunk
final_message = DummyMessage(content="done", tool_calls=[])
final_result = DummyResult(message=final_message, usage=build_usage())
runner.model_instance.invoke_llm.side_effect = [generator(), final_result]
outputs = list(runner.run(message, "query"))
assert len(outputs) >= 1
def test_run_non_streaming_list_content(self, runner):
message = MagicMock(id="m1")
content = [TextPromptMessageContent(data="hi")]
dummy_message = DummyMessage(content=content)
result = DummyResult(message=dummy_message, usage=build_usage())
runner.model_instance.invoke_llm.return_value = result
outputs = list(runner.run(message, "query"))
assert len(outputs) == 1
assert runner.save_agent_thought.call_args.kwargs["thought"] == "hi"
def test_run_streaming_tool_call_inputs_type_error(self, runner, mocker):
message = MagicMock(id="m1")
runner.stream_tool_call = True
tool_call = MagicMock()
tool_call.id = "1"
tool_call.function.name = "tool"
tool_call.function.arguments = json.dumps({"a": 1})
chunk = DummyChunk(message=DummyMessage(content="hi", tool_calls=[tool_call]), usage=build_usage())
def generator():
yield chunk
runner.model_instance.invoke_llm.return_value = generator()
real_dumps = json.dumps
def flaky_dumps(obj, *args, **kwargs):
if kwargs.get("ensure_ascii") is False:
return real_dumps(obj, *args, **kwargs)
raise TypeError("boom")
mocker.patch("core.agent.fc_agent_runner.json.dumps", side_effect=flaky_dumps)
outputs = list(runner.run(message, "query"))
assert len(outputs) == 1
def test_run_with_missing_tool_instance(self, runner):
message = MagicMock(id="m1")
tool_call = MagicMock()
tool_call.id = "1"
tool_call.function.name = "missing"
tool_call.function.arguments = json.dumps({})
dummy_message = DummyMessage(content="", tool_calls=[tool_call])
result = DummyResult(message=dummy_message, usage=build_usage())
final_message = DummyMessage(content="done", tool_calls=[])
final_result = DummyResult(message=final_message, usage=build_usage())
runner.model_instance.invoke_llm.side_effect = [result, final_result]
outputs = list(runner.run(message, "query"))
assert len(outputs) >= 1
def test_run_with_tool_instance_and_files(self, runner, mocker):
message = MagicMock(id="m1")
tool_call = MagicMock()
tool_call.id = "1"
tool_call.function.name = "tool"
tool_call.function.arguments = json.dumps({"a": 1})
dummy_message = DummyMessage(content="", tool_calls=[tool_call])
result = DummyResult(message=dummy_message, usage=build_usage())
final_result = DummyResult(message=DummyMessage(content="done", tool_calls=[]), usage=build_usage())
runner.model_instance.invoke_llm.side_effect = [result, final_result]
tool_instance = MagicMock()
prompt_tool = MagicMock()
prompt_tool.name = "tool"
runner._init_prompt_tools.return_value = ({"tool": tool_instance}, [prompt_tool])
tool_invoke_meta = MagicMock()
tool_invoke_meta.to_dict.return_value = {"ok": True}
mocker.patch(
"core.agent.fc_agent_runner.ToolEngine.agent_invoke",
return_value=("ok", ["file1"], tool_invoke_meta),
)
outputs = list(runner.run(message, "query"))
assert len(outputs) >= 1
assert any(
isinstance(call.args[0], QueueMessageFileEvent)
and call.args[0].message_file_id == "file1"
and call.args[1] == PublishFrom.APPLICATION_MANAGER
for call in runner.queue_manager.publish.call_args_list
)
def test_run_max_iteration_error(self, runner):
runner.app_config.agent.max_iteration = 0
message = MagicMock(id="m1")
tool_call = MagicMock()
tool_call.id = "1"
tool_call.function.name = "tool"
tool_call.function.arguments = "{}"
dummy_message = DummyMessage(content="", tool_calls=[tool_call])
result = DummyResult(message=dummy_message, usage=build_usage())
runner.model_instance.invoke_llm.return_value = result
with pytest.raises(AgentMaxIterationError):
list(runner.run(message, "query"))

View File

@@ -0,0 +1,324 @@
"""Unit tests for core.agent.plugin_entities.
Covers entities such as AgentFeature, AgentProviderEntityWithPlugin,
AgentStrategyEntity, AgentStrategyIdentity, AgentStrategyParameter,
AgentStrategyProviderEntity, and AgentStrategyProviderIdentity. Tests rely on
Pydantic ValidationError behavior and pytest fixtures for validation and
mocking; ensure entity invariants and validation rules remain stable.
"""
import pytest
from pydantic import ValidationError
from core.agent.plugin_entities import (
AgentFeature,
AgentProviderEntityWithPlugin,
AgentStrategyEntity,
AgentStrategyIdentity,
AgentStrategyParameter,
AgentStrategyProviderEntity,
AgentStrategyProviderIdentity,
)
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolIdentity, ToolProviderIdentity
# =========================================================
# Fixtures
# =========================================================
@pytest.fixture
def mock_identity(mocker):
return mocker.MagicMock(spec=AgentStrategyIdentity)
@pytest.fixture
def mock_provider_identity(mocker):
return mocker.MagicMock(spec=AgentStrategyProviderIdentity)
# =========================================================
# AgentStrategyParameterType Tests
# =========================================================
class TestAgentStrategyParameterType:
@pytest.mark.parametrize(
"enum_member",
list(AgentStrategyParameter.AgentStrategyParameterType),
)
def test_as_normal_type_calls_external_function(self, mocker, enum_member) -> None:
mock_func = mocker.patch(
"core.agent.plugin_entities.as_normal_type",
return_value="normalized",
)
result = enum_member.as_normal_type()
mock_func.assert_called_once_with(enum_member)
assert result == "normalized"
def test_as_normal_type_propagates_exception(self, mocker) -> None:
enum_member = AgentStrategyParameter.AgentStrategyParameterType.STRING
mocker.patch(
"core.agent.plugin_entities.as_normal_type",
side_effect=RuntimeError("boom"),
)
with pytest.raises(RuntimeError):
enum_member.as_normal_type()
@pytest.mark.parametrize(
("enum_member", "value"),
[
(AgentStrategyParameter.AgentStrategyParameterType.STRING, "abc"),
(AgentStrategyParameter.AgentStrategyParameterType.NUMBER, 10),
(AgentStrategyParameter.AgentStrategyParameterType.BOOLEAN, True),
(AgentStrategyParameter.AgentStrategyParameterType.ANY, {"a": 1}),
(AgentStrategyParameter.AgentStrategyParameterType.STRING, None),
(AgentStrategyParameter.AgentStrategyParameterType.FILES, []),
],
)
def test_cast_value_calls_external_function(self, mocker, enum_member, value) -> None:
mock_func = mocker.patch(
"core.agent.plugin_entities.cast_parameter_value",
return_value="casted",
)
result = enum_member.cast_value(value)
mock_func.assert_called_once_with(enum_member, value)
assert result == "casted"
def test_cast_value_propagates_exception(self, mocker) -> None:
enum_member = AgentStrategyParameter.AgentStrategyParameterType.STRING
mocker.patch(
"core.agent.plugin_entities.cast_parameter_value",
side_effect=ValueError("invalid"),
)
with pytest.raises(ValueError):
enum_member.cast_value("bad")
# =========================================================
# AgentStrategyParameter Tests
# =========================================================
class TestAgentStrategyParameter:
def test_valid_creation_minimal(self) -> None:
# bypass base PluginParameter required fields using model_construct
param = AgentStrategyParameter.model_construct(
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
name="test",
label="label",
help=None,
)
assert param.type == AgentStrategyParameter.AgentStrategyParameterType.STRING
assert param.help is None
def test_valid_creation_with_help(self) -> None:
help_obj = I18nObject(en_US="test")
param = AgentStrategyParameter.model_construct(
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
name="test",
label="label",
help=help_obj,
)
assert param.help == help_obj
@pytest.mark.parametrize("invalid_type", [None, "invalid_type", 999, [], {}, ["bad"], {"bad": 1}])
def test_invalid_type_raises_validation_error(self, invalid_type) -> None:
with pytest.raises(ValidationError) as exc_info:
AgentStrategyParameter(type=invalid_type, name="x", label=I18nObject(en_US="y", zh_Hans="y"))
assert any(error["loc"] == ("type",) for error in exc_info.value.errors())
def test_init_frontend_parameter_calls_external(self, mocker) -> None:
mock_func = mocker.patch(
"core.agent.plugin_entities.init_frontend_parameter",
return_value="frontend",
)
param = AgentStrategyParameter.model_construct(
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
name="test",
label="label",
)
result = param.init_frontend_parameter("value")
mock_func.assert_called_once_with(param, param.type, "value")
assert result == "frontend"
def test_init_frontend_parameter_propagates_exception(self, mocker) -> None:
mocker.patch(
"core.agent.plugin_entities.init_frontend_parameter",
side_effect=RuntimeError("error"),
)
param = AgentStrategyParameter.model_construct(
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
name="test",
label="label",
)
with pytest.raises(RuntimeError):
param.init_frontend_parameter("value")
# =========================================================
# AgentStrategyProviderEntity Tests
# =========================================================
class TestAgentStrategyProviderEntity:
def test_creation_with_plugin_id(self, mock_provider_identity) -> None:
entity = AgentStrategyProviderEntity(
identity=mock_provider_identity,
plugin_id="plugin-123",
)
assert entity.plugin_id == "plugin-123"
def test_creation_with_empty_plugin_id(self, mock_provider_identity) -> None:
entity = AgentStrategyProviderEntity(
identity=mock_provider_identity,
plugin_id="",
)
assert entity.plugin_id == ""
def test_creation_without_plugin_id(self, mock_provider_identity) -> None:
entity = AgentStrategyProviderEntity(identity=mock_provider_identity)
assert entity.plugin_id is None
def test_invalid_identity_raises(self) -> None:
with pytest.raises(ValidationError):
AgentStrategyProviderEntity(identity="invalid")
# =========================================================
# AgentStrategyEntity Tests
# =========================================================
class TestAgentStrategyEntity:
def test_parameters_default_empty(self, mock_identity) -> None:
entity = AgentStrategyEntity(
identity=mock_identity,
description=I18nObject(en_US="test"),
)
assert entity.parameters == []
def test_parameters_none_converted_to_empty(self, mock_identity) -> None:
entity = AgentStrategyEntity(
identity=mock_identity,
description=I18nObject(en_US="test"),
parameters=None,
)
assert entity.parameters == []
def test_parameters_preserved(self, mock_identity) -> None:
param = AgentStrategyParameter.model_construct(
type=AgentStrategyParameter.AgentStrategyParameterType.STRING,
name="test",
label="label",
)
entity = AgentStrategyEntity(
identity=mock_identity,
description=I18nObject(en_US="test"),
parameters=[param],
)
assert entity.parameters == [param]
def test_invalid_parameters_type_raises(self, mock_identity) -> None:
with pytest.raises(ValidationError):
AgentStrategyEntity(
identity=mock_identity,
description=I18nObject(en_US="test"),
parameters="invalid",
)
@pytest.mark.parametrize(
"features",
[
None,
[],
[AgentFeature.HISTORY_MESSAGES],
],
)
def test_features_valid(self, mock_identity, features) -> None:
entity = AgentStrategyEntity(
identity=mock_identity,
description=I18nObject(en_US="test"),
features=features,
)
assert entity.features == features
def test_invalid_features_type_raises(self, mock_identity) -> None:
with pytest.raises(ValidationError):
AgentStrategyEntity(
identity=mock_identity,
description=I18nObject(en_US="test"),
features="invalid",
)
def test_output_schema_and_meta_version(self, mock_identity) -> None:
entity = AgentStrategyEntity(
identity=mock_identity,
description=I18nObject(en_US="test"),
output_schema={"type": "object"},
meta_version="v1",
)
assert entity.output_schema == {"type": "object"}
assert entity.meta_version == "v1"
def test_missing_required_fields_raise(self, mock_identity) -> None:
with pytest.raises(ValidationError):
AgentStrategyEntity(identity=mock_identity)
# =========================================================
# AgentProviderEntityWithPlugin Tests
# =========================================================
class TestAgentProviderEntityWithPlugin:
def test_default_strategies_empty(self, mock_provider_identity) -> None:
entity = AgentProviderEntityWithPlugin(identity=mock_provider_identity)
assert entity.strategies == []
def test_strategies_assignment(self, mock_provider_identity, mock_identity) -> None:
strategy = AgentStrategyEntity.model_construct(
identity=mock_identity,
description=I18nObject(en_US="test"),
parameters=[],
)
entity = AgentProviderEntityWithPlugin(
identity=mock_provider_identity,
strategies=[strategy],
)
assert entity.strategies == [strategy]
def test_invalid_strategies_type_raises(self, mock_provider_identity) -> None:
with pytest.raises(ValidationError):
AgentProviderEntityWithPlugin(
identity=mock_provider_identity,
strategies="invalid",
)
# =========================================================
# Inheritance Smoke Tests
# =========================================================
class TestInheritanceBehavior:
def test_agent_strategy_identity_inherits(self) -> None:
assert issubclass(AgentStrategyIdentity, ToolIdentity)
def test_agent_strategy_provider_identity_inherits(self) -> None:
assert issubclass(AgentStrategyProviderIdentity, ToolProviderIdentity)

View File

@@ -0,0 +1,75 @@
from types import SimpleNamespace
from unittest.mock import patch
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from models.model import AppMode
class TestAdvancedChatAppConfigManager:
def test_get_app_config(self):
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.ADVANCED_CHAT.value)
workflow = SimpleNamespace(id="wf-1", features_dict={})
with (
patch(
"core.app.apps.advanced_chat.app_config_manager.SensitiveWordAvoidanceConfigManager.convert",
return_value=None,
),
patch(
"core.app.apps.advanced_chat.app_config_manager.WorkflowVariablesConfigManager.convert",
return_value=[],
),
):
app_config = AdvancedChatAppConfigManager.get_app_config(app_model, workflow)
assert app_config.workflow_id == "wf-1"
assert app_config.app_mode == AppMode.ADVANCED_CHAT
def test_config_validate_filters_keys(self):
def _add_key(key, value):
def _inner(*args, **kwargs):
config = kwargs.get("config") if kwargs else args[-1]
config = {**config, key: value}
return config, [key]
return _inner
with (
patch(
"core.app.apps.advanced_chat.app_config_manager.FileUploadConfigManager.validate_and_set_defaults",
side_effect=_add_key("file_upload", 1),
),
patch(
"core.app.apps.advanced_chat.app_config_manager.OpeningStatementConfigManager.validate_and_set_defaults",
side_effect=_add_key("opening_statement", 2),
),
patch(
"core.app.apps.advanced_chat.app_config_manager.SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults",
side_effect=_add_key("suggested_questions_after_answer", 3),
),
patch(
"core.app.apps.advanced_chat.app_config_manager.SpeechToTextConfigManager.validate_and_set_defaults",
side_effect=_add_key("speech_to_text", 4),
),
patch(
"core.app.apps.advanced_chat.app_config_manager.TextToSpeechConfigManager.validate_and_set_defaults",
side_effect=_add_key("text_to_speech", 5),
),
patch(
"core.app.apps.advanced_chat.app_config_manager.RetrievalResourceConfigManager.validate_and_set_defaults",
side_effect=_add_key("retriever_resource", 6),
),
patch(
"core.app.apps.advanced_chat.app_config_manager.SensitiveWordAvoidanceConfigManager.validate_and_set_defaults",
side_effect=_add_key("sensitive_word_avoidance", 7),
),
):
filtered = AdvancedChatAppConfigManager.config_validate(tenant_id="t1", config={})
assert filtered["file_upload"] == 1
assert filtered["opening_statement"] == 2
assert filtered["suggested_questions_after_answer"] == 3
assert filtered["speech_to_text"] == 4
assert filtered["text_to_speech"] == 5
assert filtered["retriever_resource"] == 6
assert filtered["sensitive_word_avoidance"] == 7

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
from collections.abc import Generator
from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter
from core.app.entities.task_entities import (
ChatbotAppBlockingResponse,
ChatbotAppStreamResponse,
ErrorStreamResponse,
MessageEndStreamResponse,
NodeFinishStreamResponse,
NodeStartStreamResponse,
PingStreamResponse,
)
from dify_graph.enums import WorkflowNodeExecutionStatus
class TestAdvancedChatGenerateResponseConverter:
def test_blocking_simple_response_metadata(self):
data = ChatbotAppBlockingResponse.Data(
id="msg-1",
mode="chat",
conversation_id="c1",
message_id="m1",
answer="hi",
metadata={"usage": {"total_tokens": 1}},
created_at=1,
)
blocking = ChatbotAppBlockingResponse(task_id="t1", data=data)
response = AdvancedChatAppGenerateResponseConverter.convert_blocking_simple_response(blocking)
assert "usage" not in response["metadata"]
def test_stream_simple_response_includes_node_events(self):
node_start = NodeStartStreamResponse(
task_id="t1",
workflow_run_id="r1",
data=NodeStartStreamResponse.Data(
id="e1",
node_id="n1",
node_type="answer",
title="Answer",
index=1,
created_at=1,
),
)
node_finish = NodeFinishStreamResponse(
task_id="t1",
workflow_run_id="r1",
data=NodeFinishStreamResponse.Data(
id="e1",
node_id="n1",
node_type="answer",
title="Answer",
index=1,
status=WorkflowNodeExecutionStatus.SUCCEEDED,
elapsed_time=0.1,
created_at=1,
finished_at=2,
),
)
def stream() -> Generator[ChatbotAppStreamResponse, None, None]:
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=PingStreamResponse(task_id="t1"),
)
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=node_start,
)
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=node_finish,
)
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=ErrorStreamResponse(task_id="t1", err=ValueError("boom")),
)
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=MessageEndStreamResponse(task_id="t1", id="m1"),
)
converted = list(AdvancedChatAppGenerateResponseConverter.convert_stream_simple_response(stream()))
assert converted[0] == "ping"
assert converted[1]["event"] == "node_started"
assert converted[2]["event"] == "node_finished"
assert converted[3]["event"] == "error"

View File

@@ -0,0 +1,600 @@
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime
from types import SimpleNamespace
import pytest
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom
from core.app.entities.queue_entities import (
QueueAdvancedChatMessageEndEvent,
QueueAnnotationReplyEvent,
QueueErrorEvent,
QueueHumanInputFormFilledEvent,
QueueHumanInputFormTimeoutEvent,
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
QueueLoopCompletedEvent,
QueueLoopNextEvent,
QueueLoopStartEvent,
QueueMessageReplaceEvent,
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueuePingEvent,
QueueRetrieverResourcesEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
QueueWorkflowPartialSuccessEvent,
QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
)
from core.app.entities.task_entities import (
AnnotationReply,
AnnotationReplyAccount,
MessageAudioStreamResponse,
MessageEndStreamResponse,
PingStreamResponse,
)
from core.base.tts.app_generator_tts_publisher import AudioTrunk
from dify_graph.enums import NodeType
from dify_graph.runtime import GraphRuntimeState, VariablePool
from dify_graph.system_variable import SystemVariable
from models.enums import MessageStatus
from models.model import AppMode, EndUser
def _make_pipeline():
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.ADVANCED_CHAT,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
application_generate_entity = AdvancedChatAppGenerateEntity.model_construct(
task_id="task",
app_config=app_config,
inputs={},
query="hello",
files=[],
user_id="user",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
extras={},
trace_manager=None,
workflow_run_id="run-id",
)
message = SimpleNamespace(
id="message-id",
query="hello",
created_at=datetime.utcnow(),
status=MessageStatus.NORMAL,
answer="",
)
conversation = SimpleNamespace(id="conv-id", mode=AppMode.ADVANCED_CHAT)
workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={})
user = EndUser(tenant_id="tenant", type="session", name="tester", session_id="session")
pipeline = AdvancedChatAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None),
conversation=conversation,
message=message,
user=user,
stream=False,
dialogue_count=1,
draft_var_saver_factory=lambda **kwargs: None,
)
return pipeline
class TestAdvancedChatGenerateTaskPipeline:
def test_ensure_workflow_initialized_raises(self):
pipeline = _make_pipeline()
with pytest.raises(ValueError, match="workflow run not initialized"):
pipeline._ensure_workflow_initialized()
def test_to_blocking_response_returns_message_end(self):
pipeline = _make_pipeline()
pipeline._task_state.answer = "done"
def _gen():
yield MessageEndStreamResponse(task_id="task", id="message-id", metadata={"k": "v"})
response = pipeline._to_blocking_response(_gen())
assert response.data.answer == "done"
assert response.data.metadata == {"k": "v"}
def test_handle_text_chunk_event_updates_state(self):
pipeline = _make_pipeline()
pipeline._message_cycle_manager = SimpleNamespace(
message_to_stream_response=lambda **kwargs: MessageEndStreamResponse(
task_id="task", id="message-id", metadata={}
)
)
event = SimpleNamespace(text="hi", from_variable_selector=None)
responses = list(pipeline._handle_text_chunk_event(event))
assert pipeline._task_state.answer == "hi"
assert responses
def test_listen_audio_msg_returns_audio_stream(self):
pipeline = _make_pipeline()
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data"))
response = pipeline._listen_audio_msg(publisher=publisher, task_id="task")
assert isinstance(response, MessageAudioStreamResponse)
def test_handle_ping_event(self):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.ping_stream_response = lambda: PingStreamResponse(task_id="task")
responses = list(pipeline._handle_ping_event(QueuePingEvent()))
assert isinstance(responses[0], PingStreamResponse)
def test_handle_error_event(self):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.handle_error = lambda **kwargs: ValueError("boom")
pipeline._base_task_pipeline.error_to_stream_response = lambda err: err
@contextmanager
def _fake_session():
yield SimpleNamespace()
pipeline._database_session = _fake_session
responses = list(pipeline._handle_error_event(QueueErrorEvent(error=ValueError("boom"))))
assert isinstance(responses[0], ValueError)
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
@contextmanager
def _fake_session():
yield SimpleNamespace()
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
monkeypatch.setattr(pipeline, "_get_message", lambda **kwargs: SimpleNamespace())
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
assert pipeline._workflow_run_id == "run-id"
assert responses == ["started"]
def test_message_end_to_stream_response_strips_annotation_reply(self):
pipeline = _make_pipeline()
pipeline._task_state.metadata.annotation_reply = AnnotationReply(
id="ann",
account=AnnotationReplyAccount(id="acc", name="acc"),
)
response = pipeline._message_end_to_stream_response()
assert "annotation_reply" not in response.metadata
def test_handle_output_moderation_chunk_publishes_stop(self):
pipeline = _make_pipeline()
events: list[object] = []
class _Moderation:
def should_direct_output(self):
return True
def get_final_output(self):
return "final"
pipeline._base_task_pipeline.output_moderation_handler = _Moderation()
pipeline._base_task_pipeline.queue_manager = SimpleNamespace(
publish=lambda event, pub_from: events.append(event)
)
result = pipeline._handle_output_moderation_chunk("ignored")
assert result is True
assert pipeline._task_state.answer == "final"
assert any(isinstance(event, QueueTextChunkEvent) for event in events)
assert any(isinstance(event, QueueStopEvent) for event in events)
def test_handle_node_succeeded_event_records_files(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: [
{"type": "file", "transfer_method": "local"}
]
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
pipeline._save_output_for_event = lambda event, node_execution_id: None
event = SimpleNamespace(
node_type=NodeType.ANSWER,
outputs={"k": "v"},
node_execution_id="exec",
node_id="node",
)
responses = list(pipeline._handle_node_succeeded_event(event))
assert responses == ["done"]
assert pipeline._recorded_files
def test_iteration_and_loop_handlers(self):
pipeline = _make_pipeline()
pipeline._workflow_run_id = "run-id"
pipeline._workflow_response_converter.workflow_iteration_start_to_stream_response = lambda **kwargs: (
"iter_start"
)
pipeline._workflow_response_converter.workflow_iteration_next_to_stream_response = lambda **kwargs: "iter_next"
pipeline._workflow_response_converter.workflow_iteration_completed_to_stream_response = lambda **kwargs: (
"iter_done"
)
pipeline._workflow_response_converter.workflow_loop_start_to_stream_response = lambda **kwargs: "loop_start"
pipeline._workflow_response_converter.workflow_loop_next_to_stream_response = lambda **kwargs: "loop_next"
pipeline._workflow_response_converter.workflow_loop_completed_to_stream_response = lambda **kwargs: "loop_done"
iter_start = QueueIterationStartEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
iter_next = QueueIterationNextEvent(
index=1,
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
node_run_index=1,
)
iter_done = QueueIterationCompletedEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
loop_start = QueueLoopStartEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
loop_next = QueueLoopNextEvent(
index=1,
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
node_run_index=1,
)
loop_done = QueueLoopCompletedEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
assert list(pipeline._handle_iteration_start_event(iter_start)) == ["iter_start"]
assert list(pipeline._handle_iteration_next_event(iter_next)) == ["iter_next"]
assert list(pipeline._handle_iteration_completed_event(iter_done)) == ["iter_done"]
assert list(pipeline._handle_loop_start_event(loop_start)) == ["loop_start"]
assert list(pipeline._handle_loop_next_event(loop_next)) == ["loop_next"]
assert list(pipeline._handle_loop_completed_event(loop_done)) == ["loop_done"]
def test_workflow_finish_handlers(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._workflow_run_id = "run-id"
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish"
pipeline._workflow_response_converter.workflow_pause_to_stream_response = lambda **kwargs: ["pause"]
pipeline._persist_human_input_extra_content = lambda **kwargs: None
pipeline._save_message = lambda **kwargs: None
pipeline._base_task_pipeline.queue_manager.publish = lambda *args, **kwargs: None
pipeline._base_task_pipeline.handle_error = lambda **kwargs: ValueError("boom")
pipeline._base_task_pipeline.error_to_stream_response = lambda err: err
pipeline._get_message = lambda **kwargs: SimpleNamespace(id="message-id")
@contextmanager
def _fake_session():
yield SimpleNamespace(scalar=lambda *args, **kwargs: None)
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
succeeded_responses = list(pipeline._handle_workflow_succeeded_event(QueueWorkflowSucceededEvent(outputs={})))
assert len(succeeded_responses) == 2
assert isinstance(succeeded_responses[0], MessageEndStreamResponse)
assert succeeded_responses[1] == "finish"
partial_success_responses = list(
pipeline._handle_workflow_partial_success_event(
QueueWorkflowPartialSuccessEvent(exceptions_count=1, outputs={})
)
)
assert len(partial_success_responses) == 2
assert isinstance(partial_success_responses[0], MessageEndStreamResponse)
assert partial_success_responses[1] == "finish"
assert (
list(pipeline._handle_workflow_failed_event(QueueWorkflowFailedEvent(error="err", exceptions_count=1)))[0]
== "finish"
)
assert list(pipeline._handle_workflow_paused_event(QueueWorkflowPausedEvent(reasons=[], outputs={}))) == [
"pause"
]
def test_node_failure_handlers(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "node_finish"
pipeline._save_output_for_event = lambda event, node_execution_id: None
failed_event = QueueNodeFailedEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
start_at=datetime.utcnow(),
inputs={},
outputs={},
process_data={},
error="err",
)
exc_event = QueueNodeExceptionEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
start_at=datetime.utcnow(),
inputs={},
outputs={},
process_data={},
error="err",
)
assert list(pipeline._handle_node_failed_events(failed_event)) == ["node_finish"]
assert list(pipeline._handle_node_failed_events(exc_event)) == ["node_finish"]
def test_handle_text_chunk_event_tracks_streaming_metrics(self):
pipeline = _make_pipeline()
published: list[object] = []
class _Publisher:
def publish(self, message):
published.append(message)
pipeline._message_cycle_manager = SimpleNamespace(message_to_stream_response=lambda **kwargs: "chunk")
event = SimpleNamespace(text="hi", from_variable_selector=["a"])
queue_message = SimpleNamespace(event=event)
responses = list(
pipeline._handle_text_chunk_event(event, tts_publisher=_Publisher(), queue_message=queue_message)
)
assert responses == ["chunk"]
assert pipeline._task_state.is_streaming_response is True
assert pipeline._task_state.first_token_time is not None
assert pipeline._task_state.last_token_time is not None
assert pipeline._task_state.answer == "hi"
assert published == [queue_message]
def test_handle_output_moderation_chunk_appends_token(self):
pipeline = _make_pipeline()
seen: list[str] = []
class _Moderation:
def should_direct_output(self):
return False
def append_new_token(self, text):
seen.append(text)
pipeline._base_task_pipeline.output_moderation_handler = _Moderation()
result = pipeline._handle_output_moderation_chunk("token")
assert result is False
assert seen == ["token"]
def test_handle_retriever_and_annotation_events(self):
pipeline = _make_pipeline()
calls = {"retriever": 0, "annotation": 0}
def _hit_retriever(event):
calls["retriever"] += 1
def _hit_annotation(event):
calls["annotation"] += 1
pipeline._message_cycle_manager.handle_retriever_resources = _hit_retriever
pipeline._message_cycle_manager.handle_annotation_reply = _hit_annotation
retriever_event = QueueRetrieverResourcesEvent(retriever_resources=[])
annotation_event = QueueAnnotationReplyEvent(message_annotation_id="ann")
assert list(pipeline._handle_retriever_resources_event(retriever_event)) == []
assert list(pipeline._handle_annotation_reply_event(annotation_event)) == []
assert calls == {"retriever": 1, "annotation": 1}
def test_handle_message_replace_event(self):
pipeline = _make_pipeline()
pipeline._message_cycle_manager.message_replace_to_stream_response = lambda **kwargs: "replace"
event = QueueMessageReplaceEvent(
text="new",
reason=QueueMessageReplaceEvent.MessageReplaceReason.OUTPUT_MODERATION,
)
assert list(pipeline._handle_message_replace_event(event)) == ["replace"]
def test_handle_human_input_events(self):
pipeline = _make_pipeline()
persisted: list[str] = []
pipeline._persist_human_input_extra_content = lambda **kwargs: persisted.append("saved")
pipeline._workflow_response_converter.human_input_form_filled_to_stream_response = lambda **kwargs: "filled"
pipeline._workflow_response_converter.human_input_form_timeout_to_stream_response = lambda **kwargs: "timeout"
filled_event = QueueHumanInputFormFilledEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="title",
rendered_content="content",
action_id="action",
action_text="action",
)
timeout_event = QueueHumanInputFormTimeoutEvent(
node_id="node",
node_type=NodeType.LLM,
node_title="title",
expiration_time=datetime.utcnow(),
)
assert list(pipeline._handle_human_input_form_filled_event(filled_event)) == ["filled"]
assert list(pipeline._handle_human_input_form_timeout_event(timeout_event)) == ["timeout"]
assert persisted == ["saved"]
def test_save_message_strips_markdown_and_sets_usage(self):
pipeline = _make_pipeline()
pipeline._recorded_files = [
{
"type": "image",
"transfer_method": "remote",
"remote_url": "http://example.com/file.png",
"related_id": "file-id",
}
]
pipeline._task_state.answer = "![img](url) hello"
pipeline._task_state.is_streaming_response = True
pipeline._task_state.first_token_time = pipeline._base_task_pipeline.start_at + 0.1
pipeline._task_state.last_token_time = pipeline._base_task_pipeline.start_at + 0.2
message = SimpleNamespace(
id="message-id",
status=MessageStatus.PAUSED,
answer="",
updated_at=None,
provider_response_latency=None,
message_tokens=None,
message_unit_price=None,
message_price_unit=None,
answer_tokens=None,
answer_unit_price=None,
answer_price_unit=None,
total_price=None,
currency=None,
message_metadata=None,
invoke_from=InvokeFrom.WEB_APP,
from_account_id=None,
from_end_user_id="end-user",
)
class _Session:
def scalar(self, *args, **kwargs):
return message
def add_all(self, items):
self.items = items
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._save_message(session=_Session(), graph_runtime_state=graph_runtime_state)
assert message.status == MessageStatus.NORMAL
assert message.answer == "hello"
assert message.message_metadata
def test_handle_stop_event_saves_message_for_moderation(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._message_end_to_stream_response = lambda: "end"
saved: list[str] = []
def _save_message(**kwargs):
saved.append("saved")
pipeline._save_message = _save_message
@contextmanager
def _fake_session():
yield SimpleNamespace()
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
responses = list(pipeline._handle_stop_event(QueueStopEvent(stopped_by=QueueStopEvent.StopBy.INPUT_MODERATION)))
assert responses == ["end"]
assert saved == ["saved"]
def test_handle_message_end_event_applies_output_moderation(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._base_task_pipeline.handle_output_moderation_when_task_finished = lambda answer: "safe"
pipeline._message_cycle_manager.message_replace_to_stream_response = lambda **kwargs: "replace"
pipeline._message_end_to_stream_response = lambda: "end"
saved: list[str] = []
def _save_message(**kwargs):
saved.append("saved")
pipeline._save_message = _save_message
@contextmanager
def _fake_session():
yield SimpleNamespace()
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
responses = list(pipeline._handle_advanced_chat_message_end_event(QueueAdvancedChatMessageEndEvent()))
assert responses == ["replace", "end"]
assert saved == ["saved"]
def test_dispatch_event_handles_node_exception(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "failed"
pipeline._save_output_for_event = lambda *args, **kwargs: None
event = QueueNodeExceptionEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
start_at=datetime.utcnow(),
inputs={},
outputs={},
process_data={},
error="err",
)
assert list(pipeline._dispatch_event(event)) == ["failed"]

View File

@@ -0,0 +1,302 @@
import uuid
from types import SimpleNamespace
import pytest
from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom
from core.app.apps.agent_chat.app_config_manager import (
AgentChatAppConfigManager,
)
from core.entities.agent_entities import PlanningStrategy
class TestAgentChatAppConfigManagerGetAppConfig:
def test_get_app_config_override_config(self, mocker):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat")
app_model_config = mocker.MagicMock(id="cfg1")
app_model_config.to_dict.return_value = {"ignored": True}
override_config = {"model": {"provider": "p"}}
mocker.patch("core.app.apps.agent_chat.app_config_manager.ModelConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.PromptTemplateConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.SensitiveWordAvoidanceConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.AgentConfigManager.convert")
mocker.patch.object(AgentChatAppConfigManager, "convert_features")
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.BasicVariablesConfigManager.convert",
return_value=("variables", "external"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.AgentChatAppConfig",
side_effect=lambda **kwargs: SimpleNamespace(**kwargs),
)
result = AgentChatAppConfigManager.get_app_config(
app_model=app_model,
app_model_config=app_model_config,
conversation=None,
override_config_dict=override_config,
)
assert result.app_model_config_dict == override_config
assert result.app_model_config_from == EasyUIBasedAppModelConfigFrom.ARGS
assert result.variables == "variables"
assert result.external_data_variables == "external"
def test_get_app_config_conversation_specific(self, mocker):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat")
app_model_config = mocker.MagicMock(id="cfg1")
app_model_config.to_dict.return_value = {"model": {"provider": "p"}}
conversation = mocker.MagicMock()
mocker.patch("core.app.apps.agent_chat.app_config_manager.ModelConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.PromptTemplateConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.SensitiveWordAvoidanceConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.AgentConfigManager.convert")
mocker.patch.object(AgentChatAppConfigManager, "convert_features")
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.BasicVariablesConfigManager.convert",
return_value=("variables", "external"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.AgentChatAppConfig",
side_effect=lambda **kwargs: SimpleNamespace(**kwargs),
)
result = AgentChatAppConfigManager.get_app_config(
app_model=app_model,
app_model_config=app_model_config,
conversation=conversation,
override_config_dict=None,
)
assert result.app_model_config_dict == app_model_config.to_dict.return_value
assert result.app_model_config_from.value == "conversation-specific-config"
def test_get_app_config_latest_config(self, mocker):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat")
app_model_config = mocker.MagicMock(id="cfg1")
app_model_config.to_dict.return_value = {"model": {"provider": "p"}}
mocker.patch("core.app.apps.agent_chat.app_config_manager.ModelConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.PromptTemplateConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.SensitiveWordAvoidanceConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.convert")
mocker.patch("core.app.apps.agent_chat.app_config_manager.AgentConfigManager.convert")
mocker.patch.object(AgentChatAppConfigManager, "convert_features")
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.BasicVariablesConfigManager.convert",
return_value=("variables", "external"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.AgentChatAppConfig",
side_effect=lambda **kwargs: SimpleNamespace(**kwargs),
)
result = AgentChatAppConfigManager.get_app_config(
app_model=app_model,
app_model_config=app_model_config,
conversation=None,
override_config_dict=None,
)
assert result.app_model_config_from.value == "app-latest-config"
class TestAgentChatAppConfigManagerConfigValidate:
def test_config_validate_filters_related_keys(self, mocker):
config = {
"model": {},
"user_input_form": {},
"file_upload": {},
"prompt_template": {},
"agent_mode": {},
"opening_statement": {},
"suggested_questions_after_answer": {},
"speech_to_text": {},
"text_to_speech": {},
"retriever_resource": {},
"dataset": {},
"moderation": {},
"extra": "value",
}
def return_with_key(key):
return config, [key]
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.ModelConfigManager.validate_and_set_defaults",
side_effect=lambda tenant_id, cfg: return_with_key("model"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.BasicVariablesConfigManager.validate_and_set_defaults",
side_effect=lambda tenant_id, cfg: return_with_key("user_input_form"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.FileUploadConfigManager.validate_and_set_defaults",
side_effect=lambda cfg: return_with_key("file_upload"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.PromptTemplateConfigManager.validate_and_set_defaults",
side_effect=lambda app_mode, cfg: return_with_key("prompt_template"),
)
mocker.patch.object(
AgentChatAppConfigManager,
"validate_agent_mode_and_set_defaults",
side_effect=lambda tenant_id, cfg: return_with_key("agent_mode"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.OpeningStatementConfigManager.validate_and_set_defaults",
side_effect=lambda cfg: return_with_key("opening_statement"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults",
side_effect=lambda cfg: return_with_key("suggested_questions_after_answer"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.SpeechToTextConfigManager.validate_and_set_defaults",
side_effect=lambda cfg: return_with_key("speech_to_text"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.TextToSpeechConfigManager.validate_and_set_defaults",
side_effect=lambda cfg: return_with_key("text_to_speech"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.RetrievalResourceConfigManager.validate_and_set_defaults",
side_effect=lambda cfg: return_with_key("retriever_resource"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.validate_and_set_defaults",
side_effect=lambda tenant_id, app_mode, cfg: return_with_key("dataset"),
)
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.SensitiveWordAvoidanceConfigManager.validate_and_set_defaults",
side_effect=lambda tenant_id, cfg: return_with_key("moderation"),
)
filtered = AgentChatAppConfigManager.config_validate("tenant", config)
assert set(filtered.keys()) == {
"model",
"user_input_form",
"file_upload",
"prompt_template",
"agent_mode",
"opening_statement",
"suggested_questions_after_answer",
"speech_to_text",
"text_to_speech",
"retriever_resource",
"dataset",
"moderation",
}
assert "extra" not in filtered
class TestValidateAgentModeAndSetDefaults:
def test_defaults_when_missing(self):
config = {}
updated, keys = AgentChatAppConfigManager.validate_agent_mode_and_set_defaults("tenant", config)
assert "agent_mode" in updated
assert updated["agent_mode"]["enabled"] is False
assert updated["agent_mode"]["tools"] == []
assert keys == ["agent_mode"]
@pytest.mark.parametrize(
"agent_mode",
["invalid", 123],
)
def test_agent_mode_type_validation(self, agent_mode):
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults("tenant", {"agent_mode": agent_mode})
def test_agent_mode_empty_list_defaults(self):
config = {"agent_mode": []}
updated, _ = AgentChatAppConfigManager.validate_agent_mode_and_set_defaults("tenant", config)
assert updated["agent_mode"]["enabled"] is False
assert updated["agent_mode"]["tools"] == []
def test_enabled_must_be_bool(self):
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults("tenant", {"agent_mode": {"enabled": "yes"}})
def test_strategy_must_be_valid(self):
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults(
"tenant", {"agent_mode": {"enabled": True, "strategy": "invalid"}}
)
def test_tools_must_be_list(self):
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults(
"tenant", {"agent_mode": {"enabled": True, "tools": "not-list"}}
)
def test_old_tool_dataset_requires_id(self):
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults(
"tenant", {"agent_mode": {"enabled": True, "tools": [{"dataset": {"enabled": True}}]}}
)
def test_old_tool_dataset_id_must_be_uuid(self):
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults(
"tenant",
{"agent_mode": {"enabled": True, "tools": [{"dataset": {"enabled": True, "id": "bad"}}]}},
)
def test_old_tool_dataset_id_not_exists(self, mocker):
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.is_dataset_exists",
return_value=False,
)
dataset_id = str(uuid.uuid4())
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults(
"tenant",
{"agent_mode": {"enabled": True, "tools": [{"dataset": {"enabled": True, "id": dataset_id}}]}},
)
def test_old_tool_enabled_must_be_bool(self):
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults(
"tenant",
{"agent_mode": {"enabled": True, "tools": [{"dataset": {"enabled": "yes", "id": str(uuid.uuid4())}}]}},
)
@pytest.mark.parametrize("missing_key", ["provider_type", "provider_id", "tool_name", "tool_parameters"])
def test_new_style_tool_requires_fields(self, missing_key):
tool = {"enabled": True, "provider_type": "type", "provider_id": "id", "tool_name": "tool"}
tool.pop(missing_key, None)
with pytest.raises(ValueError):
AgentChatAppConfigManager.validate_agent_mode_and_set_defaults(
"tenant", {"agent_mode": {"enabled": True, "tools": [tool]}}
)
def test_valid_old_and_new_style_tools(self, mocker):
mocker.patch(
"core.app.apps.agent_chat.app_config_manager.DatasetConfigManager.is_dataset_exists",
return_value=True,
)
dataset_id = str(uuid.uuid4())
config = {
"agent_mode": {
"enabled": True,
"strategy": PlanningStrategy.ROUTER.value,
"tools": [
{"dataset": {"id": dataset_id}},
{
"provider_type": "builtin",
"provider_id": "p1",
"tool_name": "tool",
"tool_parameters": {},
},
],
}
}
updated, _ = AgentChatAppConfigManager.validate_agent_mode_and_set_defaults("tenant", config)
assert updated["agent_mode"]["tools"][0]["dataset"]["enabled"] is False
assert updated["agent_mode"]["tools"][1]["enabled"] is False

View File

@@ -0,0 +1,296 @@
import contextlib
import pytest
from pydantic import ValidationError
from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom
from dify_graph.model_runtime.errors.invoke import InvokeAuthorizationError
class DummyAccount:
def __init__(self, user_id):
self.id = user_id
self.session_id = f"session-{user_id}"
@pytest.fixture
def generator(mocker):
gen = AgentChatAppGenerator()
mocker.patch(
"core.app.apps.agent_chat.app_generator.current_app",
new=mocker.MagicMock(_get_current_object=mocker.MagicMock()),
)
mocker.patch("core.app.apps.agent_chat.app_generator.contextvars.copy_context", return_value="ctx")
return gen
class TestAgentChatAppGeneratorGenerate:
def test_generate_rejects_blocking_mode(self, generator, mocker):
app_model = mocker.MagicMock()
user = DummyAccount("user")
with pytest.raises(ValueError):
generator.generate(app_model=app_model, user=user, args={}, invoke_from=mocker.MagicMock(), streaming=False)
def test_generate_requires_query(self, generator, mocker):
app_model = mocker.MagicMock()
user = DummyAccount("user")
with pytest.raises(ValueError):
generator.generate(app_model=app_model, user=user, args={"inputs": {}}, invoke_from=mocker.MagicMock())
def test_generate_rejects_non_string_query(self, generator, mocker):
app_model = mocker.MagicMock()
user = DummyAccount("user")
with pytest.raises(ValueError):
generator.generate(
app_model=app_model,
user=user,
args={"query": 123, "inputs": {}},
invoke_from=mocker.MagicMock(),
)
def test_generate_override_requires_debugger(self, generator, mocker):
app_model = mocker.MagicMock()
user = DummyAccount("user")
with pytest.raises(ValueError):
generator.generate(
app_model=app_model,
user=user,
args={"query": "hi", "inputs": {}, "model_config": {"model": {"provider": "p"}}},
invoke_from=InvokeFrom.WEB_APP,
)
def test_generate_success_with_debugger_override(self, generator, mocker):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat")
app_model_config = mocker.MagicMock(id="cfg1")
app_model_config.to_dict.return_value = {"model": {"provider": "p"}}
user = DummyAccount("user")
invoke_from = InvokeFrom.DEBUGGER
generator._get_app_model_config = mocker.MagicMock(return_value=app_model_config)
generator._prepare_user_inputs = mocker.MagicMock(return_value={"x": 1})
generator._init_generate_records = mocker.MagicMock(
return_value=(mocker.MagicMock(id="conv", mode="agent-chat"), mocker.MagicMock(id="msg"))
)
generator._handle_response = mocker.MagicMock(return_value="response")
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppConfigManager.config_validate",
return_value={"validated": True},
)
app_config = mocker.MagicMock(variables={}, prompt_template=mocker.MagicMock(), external_data_variables=[])
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppConfigManager.get_app_config",
return_value=app_config,
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.ModelConfigConverter.convert",
return_value=mocker.MagicMock(),
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.FileUploadConfigManager.convert",
return_value=mocker.MagicMock(),
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.file_factory.build_from_mappings",
return_value=["file-obj"],
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.ConversationService.get_conversation",
return_value=mocker.MagicMock(id="conv"),
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.TraceQueueManager",
return_value=mocker.MagicMock(),
)
queue_manager = mocker.MagicMock()
mocker.patch(
"core.app.apps.agent_chat.app_generator.MessageBasedAppQueueManager",
return_value=queue_manager,
)
thread_obj = mocker.MagicMock()
mocker.patch(
"core.app.apps.agent_chat.app_generator.threading.Thread",
return_value=thread_obj,
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppGenerateResponseConverter.convert",
return_value={"result": "ok"},
)
app_entity = mocker.MagicMock(task_id="task", user_id="user", invoke_from=invoke_from)
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppGenerateEntity",
return_value=app_entity,
)
args = {
"query": "hello",
"inputs": {"name": "world"},
"conversation_id": "conv",
"model_config": {"model": {"provider": "p"}},
"files": [{"id": "f1"}],
}
result = generator.generate(app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=True)
assert result == {"result": "ok"}
thread_obj.start.assert_called_once()
def test_generate_without_file_config(self, generator, mocker):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent-chat")
app_model_config = mocker.MagicMock(id="cfg1")
app_model_config.to_dict.return_value = {"model": {"provider": "p"}}
user = DummyAccount("user")
generator._get_app_model_config = mocker.MagicMock(return_value=app_model_config)
generator._prepare_user_inputs = mocker.MagicMock(return_value={"x": 1})
generator._init_generate_records = mocker.MagicMock(
return_value=(mocker.MagicMock(id="conv", mode="agent-chat"), mocker.MagicMock(id="msg"))
)
generator._handle_response = mocker.MagicMock(return_value="response")
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppConfigManager.get_app_config",
return_value=mocker.MagicMock(variables={}, prompt_template=mocker.MagicMock(), external_data_variables=[]),
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.ModelConfigConverter.convert",
return_value=mocker.MagicMock(),
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.FileUploadConfigManager.convert",
return_value=None,
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.file_factory.build_from_mappings",
return_value=["file-obj"],
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.TraceQueueManager",
return_value=mocker.MagicMock(),
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.MessageBasedAppQueueManager",
return_value=mocker.MagicMock(),
)
thread_obj = mocker.MagicMock()
mocker.patch(
"core.app.apps.agent_chat.app_generator.threading.Thread",
return_value=thread_obj,
)
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppGenerateResponseConverter.convert",
return_value={"result": "ok"},
)
app_entity = mocker.MagicMock(task_id="task", user_id="user", invoke_from=InvokeFrom.WEB_APP)
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppGenerateEntity",
return_value=app_entity,
)
args = {"query": "hello", "inputs": {"name": "world"}}
result = generator.generate(
app_model=app_model,
user=user,
args=args,
invoke_from=InvokeFrom.WEB_APP,
streaming=True,
)
assert result == {"result": "ok"}
class TestAgentChatAppGeneratorWorker:
@pytest.fixture(autouse=True)
def patch_context(self, mocker):
@contextlib.contextmanager
def ctx_manager(*args, **kwargs):
yield
mocker.patch("core.app.apps.agent_chat.app_generator.preserve_flask_contexts", ctx_manager)
def test_generate_worker_handles_generate_task_stopped(self, generator, mocker):
queue_manager = mocker.MagicMock()
generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock())
generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock())
runner = mocker.MagicMock()
runner.run.side_effect = GenerateTaskStoppedError()
mocker.patch("core.app.apps.agent_chat.app_generator.AgentChatAppRunner", return_value=runner)
mocker.patch("core.app.apps.agent_chat.app_generator.db.session.close")
generator._generate_worker(
flask_app=mocker.MagicMock(),
context=mocker.MagicMock(),
application_generate_entity=mocker.MagicMock(),
queue_manager=queue_manager,
conversation_id="conv",
message_id="msg",
)
queue_manager.publish_error.assert_not_called()
@pytest.mark.parametrize(
"error",
[
InvokeAuthorizationError("bad"),
ValidationError.from_exception_data("TestModel", []),
ValueError("bad"),
Exception("bad"),
],
)
def test_generate_worker_publishes_errors(self, generator, mocker, error):
queue_manager = mocker.MagicMock()
generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock())
generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock())
runner = mocker.MagicMock()
runner.run.side_effect = error
mocker.patch("core.app.apps.agent_chat.app_generator.AgentChatAppRunner", return_value=runner)
mocker.patch("core.app.apps.agent_chat.app_generator.db.session.close")
generator._generate_worker(
flask_app=mocker.MagicMock(),
context=mocker.MagicMock(),
application_generate_entity=mocker.MagicMock(),
queue_manager=queue_manager,
conversation_id="conv",
message_id="msg",
)
assert queue_manager.publish_error.called
def test_generate_worker_logs_value_error_when_debug(self, generator, mocker):
queue_manager = mocker.MagicMock()
generator._get_conversation = mocker.MagicMock(return_value=mocker.MagicMock())
generator._get_message = mocker.MagicMock(return_value=mocker.MagicMock())
runner = mocker.MagicMock()
runner.run.side_effect = ValueError("bad")
mocker.patch("core.app.apps.agent_chat.app_generator.AgentChatAppRunner", return_value=runner)
mocker.patch("core.app.apps.agent_chat.app_generator.db.session.close")
mocker.patch("core.app.apps.agent_chat.app_generator.dify_config", new=mocker.MagicMock(DEBUG=True))
logger = mocker.patch("core.app.apps.agent_chat.app_generator.logger")
generator._generate_worker(
flask_app=mocker.MagicMock(),
context=mocker.MagicMock(),
application_generate_entity=mocker.MagicMock(),
queue_manager=queue_manager,
conversation_id="conv",
message_id="msg",
)
logger.exception.assert_called_once()

View File

@@ -0,0 +1,413 @@
import pytest
from core.agent.entities import AgentEntity
from core.app.apps.agent_chat.app_runner import AgentChatAppRunner
from core.moderation.base import ModerationError
from dify_graph.model_runtime.entities.llm_entities import LLMMode
from dify_graph.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey
@pytest.fixture
def runner():
return AgentChatAppRunner()
class TestAgentChatAppRunnerRun:
def test_run_app_not_found(self, runner, mocker):
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", agent=mocker.MagicMock())
generate_entity = mocker.MagicMock(app_config=app_config, inputs={}, query="q", files=[], stream=True)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=None)
with pytest.raises(ValueError):
runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
def test_run_moderation_error_direct_output(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = mocker.MagicMock()
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(),
conversation_id=None,
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", side_effect=ModerationError("bad"))
mocker.patch.object(runner, "direct_output")
runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
runner.direct_output.assert_called_once()
def test_run_annotation_reply_short_circuits(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = mocker.MagicMock()
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(),
conversation_id=None,
user_id="user",
invoke_from=mocker.MagicMock(),
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
annotation = mocker.MagicMock(id="anno", content="answer")
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=annotation)
mocker.patch.object(runner, "direct_output")
queue_manager = mocker.MagicMock()
runner.run(generate_entity, queue_manager, mocker.MagicMock(), mocker.MagicMock())
queue_manager.publish.assert_called_once()
runner.direct_output.assert_called_once()
def test_run_hosting_moderation_short_circuits(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = mocker.MagicMock()
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(),
conversation_id=None,
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=True)
runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
def test_run_model_schema_missing(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT)
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(
provider_model_bundle=mocker.MagicMock(),
model="m",
provider="p",
credentials={"k": "v"},
),
conversation_id="conv",
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=False)
llm_instance = mocker.MagicMock()
llm_instance.model_type_instance.get_model_schema.return_value = None
mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance)
with pytest.raises(ValueError):
runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(), mocker.MagicMock())
@pytest.mark.parametrize(
("mode", "expected_runner"),
[
(LLMMode.CHAT, "CotChatAgentRunner"),
(LLMMode.COMPLETION, "CotCompletionAgentRunner"),
],
)
def test_run_chain_of_thought_modes(self, runner, mocker, mode, expected_runner):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT)
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(
provider_model_bundle=mocker.MagicMock(),
model="m",
provider="p",
credentials={"k": "v"},
),
conversation_id="conv",
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=False)
model_schema = mocker.MagicMock()
model_schema.features = []
model_schema.model_properties = {ModelPropertyKey.MODE: mode}
llm_instance = mocker.MagicMock()
llm_instance.model_type_instance.get_model_schema.return_value = model_schema
mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance)
conversation = mocker.MagicMock(id="conv")
message = mocker.MagicMock(id="msg")
mocker.patch(
"core.app.apps.agent_chat.app_runner.db.session.scalar",
side_effect=[app_record, conversation, message],
)
runner_cls = mocker.MagicMock()
mocker.patch(f"core.app.apps.agent_chat.app_runner.{expected_runner}", runner_cls)
runner_instance = mocker.MagicMock()
runner_cls.return_value = runner_instance
runner_instance.run.return_value = []
mocker.patch.object(runner, "_handle_invoke_result")
runner.run(generate_entity, mocker.MagicMock(), conversation, message)
runner_instance.run.assert_called_once()
runner._handle_invoke_result.assert_called_once()
def test_run_invalid_llm_mode_raises(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT)
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(
provider_model_bundle=mocker.MagicMock(),
model="m",
provider="p",
credentials={"k": "v"},
),
conversation_id="conv",
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=False)
model_schema = mocker.MagicMock()
model_schema.features = []
model_schema.model_properties = {ModelPropertyKey.MODE: "invalid"}
llm_instance = mocker.MagicMock()
llm_instance.model_type_instance.get_model_schema.return_value = model_schema
mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance)
conversation = mocker.MagicMock(id="conv")
message = mocker.MagicMock(id="msg")
mocker.patch(
"core.app.apps.agent_chat.app_runner.db.session.scalar",
side_effect=[app_record, conversation, message],
)
with pytest.raises(ValueError):
runner.run(generate_entity, mocker.MagicMock(), conversation, message)
def test_run_function_calling_strategy_selected_by_features(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT)
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(
provider_model_bundle=mocker.MagicMock(),
model="m",
provider="p",
credentials={"k": "v"},
),
conversation_id="conv",
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=False)
model_schema = mocker.MagicMock()
model_schema.features = [ModelFeature.TOOL_CALL]
model_schema.model_properties = {ModelPropertyKey.MODE: LLMMode.CHAT}
llm_instance = mocker.MagicMock()
llm_instance.model_type_instance.get_model_schema.return_value = model_schema
mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance)
conversation = mocker.MagicMock(id="conv")
message = mocker.MagicMock(id="msg")
mocker.patch(
"core.app.apps.agent_chat.app_runner.db.session.scalar",
side_effect=[app_record, conversation, message],
)
runner_cls = mocker.MagicMock()
mocker.patch("core.app.apps.agent_chat.app_runner.FunctionCallAgentRunner", runner_cls)
runner_instance = mocker.MagicMock()
runner_cls.return_value = runner_instance
runner_instance.run.return_value = []
mocker.patch.object(runner, "_handle_invoke_result")
runner.run(generate_entity, mocker.MagicMock(), conversation, message)
assert app_config.agent.strategy == AgentEntity.Strategy.FUNCTION_CALLING
runner_instance.run.assert_called_once()
def test_run_conversation_not_found(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.FUNCTION_CALLING)
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(
provider_model_bundle=mocker.MagicMock(),
model="m",
provider="p",
credentials={"k": "v"},
),
conversation_id="conv",
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch(
"core.app.apps.agent_chat.app_runner.db.session.scalar",
side_effect=[app_record, None],
)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=False)
with pytest.raises(ValueError):
runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(id="conv"), mocker.MagicMock(id="msg"))
def test_run_message_not_found(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.FUNCTION_CALLING)
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(
provider_model_bundle=mocker.MagicMock(),
model="m",
provider="p",
credentials={"k": "v"},
),
conversation_id="conv",
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch(
"core.app.apps.agent_chat.app_runner.db.session.scalar",
side_effect=[app_record, mocker.MagicMock(id="conv"), None],
)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=False)
with pytest.raises(ValueError):
runner.run(generate_entity, mocker.MagicMock(), mocker.MagicMock(id="conv"), mocker.MagicMock(id="msg"))
def test_run_invalid_agent_strategy_raises(self, runner, mocker):
app_record = mocker.MagicMock(id="app1", tenant_id="tenant")
app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock())
app_config.agent = mocker.MagicMock(strategy="invalid", provider="p", model="m")
generate_entity = mocker.MagicMock(
app_config=app_config,
inputs={},
query="q",
files=[],
stream=True,
model_conf=mocker.MagicMock(
provider_model_bundle=mocker.MagicMock(),
model="m",
provider="p",
credentials={"k": "v"},
),
conversation_id="conv",
invoke_from=mocker.MagicMock(),
user_id="user",
)
mocker.patch("core.app.apps.agent_chat.app_runner.db.session.scalar", return_value=app_record)
mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None))
mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q"))
mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None)
mocker.patch.object(runner, "check_hosting_moderation", return_value=False)
model_schema = mocker.MagicMock()
model_schema.features = []
model_schema.model_properties = {ModelPropertyKey.MODE: LLMMode.CHAT}
llm_instance = mocker.MagicMock()
llm_instance.model_type_instance.get_model_schema.return_value = model_schema
mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance)
conversation = mocker.MagicMock(id="conv")
message = mocker.MagicMock(id="msg")
mocker.patch(
"core.app.apps.agent_chat.app_runner.db.session.scalar",
side_effect=[app_record, conversation, message],
)
with pytest.raises(ValueError):
runner.run(generate_entity, mocker.MagicMock(), conversation, message)

View File

@@ -0,0 +1,162 @@
from collections.abc import Generator
from core.app.apps.agent_chat.generate_response_converter import AgentChatAppGenerateResponseConverter
from core.app.entities.task_entities import (
ChatbotAppBlockingResponse,
ChatbotAppStreamResponse,
ErrorStreamResponse,
MessageEndStreamResponse,
MessageStreamResponse,
PingStreamResponse,
)
class TestAgentChatAppGenerateResponseConverterBlocking:
def test_convert_blocking_full_response(self):
blocking = ChatbotAppBlockingResponse(
task_id="task",
data=ChatbotAppBlockingResponse.Data(
id="id",
mode="agent-chat",
conversation_id="conv",
message_id="msg",
answer="answer",
metadata={"a": 1},
created_at=123,
),
)
result = AgentChatAppGenerateResponseConverter.convert_blocking_full_response(blocking)
assert result["event"] == "message"
assert result["answer"] == "answer"
assert result["metadata"] == {"a": 1}
def test_convert_blocking_simple_response_with_dict_metadata(self):
blocking = ChatbotAppBlockingResponse(
task_id="task",
data=ChatbotAppBlockingResponse.Data(
id="id",
mode="agent-chat",
conversation_id="conv",
message_id="msg",
answer="answer",
metadata={
"retriever_resources": [
{
"segment_id": "s1",
"position": 1,
"document_name": "doc",
"score": 0.9,
"content": "content",
}
],
"annotation_reply": {"id": "a"},
"usage": {"prompt_tokens": 1},
},
created_at=123,
),
)
result = AgentChatAppGenerateResponseConverter.convert_blocking_simple_response(blocking)
assert "annotation_reply" not in result["metadata"]
assert "usage" not in result["metadata"]
def test_convert_blocking_simple_response_with_non_dict_metadata(self):
blocking = ChatbotAppBlockingResponse.model_construct(
task_id="task",
data=ChatbotAppBlockingResponse.Data.model_construct(
id="id",
mode="agent-chat",
conversation_id="conv",
message_id="msg",
answer="answer",
metadata="bad",
created_at=123,
),
)
result = AgentChatAppGenerateResponseConverter.convert_blocking_simple_response(blocking)
assert result["metadata"] == {}
class TestAgentChatAppGenerateResponseConverterStream:
def build_stream(self) -> Generator[ChatbotAppStreamResponse, None, None]:
def _gen():
yield ChatbotAppStreamResponse(
conversation_id="conv",
message_id="msg",
created_at=1,
stream_response=PingStreamResponse(task_id="t"),
)
yield ChatbotAppStreamResponse(
conversation_id="conv",
message_id="msg",
created_at=2,
stream_response=MessageStreamResponse(task_id="t", id="m1", answer="hi"),
)
yield ChatbotAppStreamResponse(
conversation_id="conv",
message_id="msg",
created_at=3,
stream_response=MessageEndStreamResponse(
task_id="t",
id="m1",
metadata={
"retriever_resources": [
{
"segment_id": "s1",
"position": 1,
"document_name": "doc",
"score": 0.9,
"content": "content",
"summary": "summary",
"extra": "ignored",
}
],
"annotation_reply": {"id": "a"},
"usage": {"prompt_tokens": 1},
},
),
)
yield ChatbotAppStreamResponse(
conversation_id="conv",
message_id="msg",
created_at=4,
stream_response=ErrorStreamResponse(task_id="t", err=RuntimeError("bad")),
)
return _gen()
def test_convert_stream_full_response(self):
items = list(AgentChatAppGenerateResponseConverter.convert_stream_full_response(self.build_stream()))
assert items[0] == "ping"
assert items[1]["event"] == "message"
assert "answer" in items[1]
assert items[2]["event"] == "message_end"
assert items[3]["event"] == "error"
def test_convert_stream_simple_response(self):
items = list(AgentChatAppGenerateResponseConverter.convert_stream_simple_response(self.build_stream()))
assert items[0] == "ping"
# Assert the message event structure and content at items[1]
assert items[1]["event"] == "message"
assert items[1]["answer"] == "hi" or "hi" in items[1]["answer"]
assert items[2]["event"] == "message_end"
assert "metadata" in items[2]
metadata = items[2]["metadata"]
assert "annotation_reply" not in metadata
assert "usage" not in metadata
assert metadata["retriever_resources"] == [
{
"segment_id": "s1",
"position": 1,
"document_name": "doc",
"score": 0.9,
"content": "content",
"summary": "summary",
}
]
assert items[3]["event"] == "error"

View File

@@ -0,0 +1,113 @@
from types import SimpleNamespace
from unittest.mock import patch
from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom, ModelConfigEntity, PromptTemplateEntity
from core.app.apps.chat.app_config_manager import ChatAppConfigManager
from models.model import AppMode
class TestChatAppConfigManager:
def test_get_app_config_uses_override_dict(self):
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.CHAT.value)
app_model_config = SimpleNamespace(id="config-1", to_dict=lambda: {"model": "m"})
override = {"model": "override"}
model_entity = ModelConfigEntity(provider="p", model="m")
prompt_entity = PromptTemplateEntity(
prompt_type=PromptTemplateEntity.PromptType.SIMPLE,
simple_prompt_template="hi",
)
with (
patch("core.app.apps.chat.app_config_manager.ModelConfigManager.convert", return_value=model_entity),
patch(
"core.app.apps.chat.app_config_manager.PromptTemplateConfigManager.convert", return_value=prompt_entity
),
patch(
"core.app.apps.chat.app_config_manager.SensitiveWordAvoidanceConfigManager.convert",
return_value=None,
),
patch("core.app.apps.chat.app_config_manager.DatasetConfigManager.convert", return_value=None),
patch("core.app.apps.chat.app_config_manager.BasicVariablesConfigManager.convert", return_value=([], [])),
):
app_config = ChatAppConfigManager.get_app_config(
app_model=app_model,
app_model_config=app_model_config,
conversation=None,
override_config_dict=override,
)
assert app_config.app_model_config_from == EasyUIBasedAppModelConfigFrom.ARGS
assert app_config.app_model_config_dict == override
assert app_config.app_mode == AppMode.CHAT
def test_config_validate_filters_related_keys(self):
config = {"extra": 1}
def _add_key(key, value):
def _inner(*args, **kwargs):
config = args[-1]
config = {**config, key: value}
return config, [key]
return _inner
with (
patch(
"core.app.apps.chat.app_config_manager.ModelConfigManager.validate_and_set_defaults",
side_effect=_add_key("model", 1),
),
patch(
"core.app.apps.chat.app_config_manager.BasicVariablesConfigManager.validate_and_set_defaults",
side_effect=_add_key("inputs", 2),
),
patch(
"core.app.apps.chat.app_config_manager.FileUploadConfigManager.validate_and_set_defaults",
side_effect=_add_key("file_upload", 3),
),
patch(
"core.app.apps.chat.app_config_manager.PromptTemplateConfigManager.validate_and_set_defaults",
side_effect=_add_key("prompt", 4),
),
patch(
"core.app.apps.chat.app_config_manager.DatasetConfigManager.validate_and_set_defaults",
side_effect=_add_key("dataset", 5),
),
patch(
"core.app.apps.chat.app_config_manager.OpeningStatementConfigManager.validate_and_set_defaults",
side_effect=_add_key("opening_statement", 6),
),
patch(
"core.app.apps.chat.app_config_manager.SuggestedQuestionsAfterAnswerConfigManager.validate_and_set_defaults",
side_effect=_add_key("suggested_questions_after_answer", 7),
),
patch(
"core.app.apps.chat.app_config_manager.SpeechToTextConfigManager.validate_and_set_defaults",
side_effect=_add_key("speech_to_text", 8),
),
patch(
"core.app.apps.chat.app_config_manager.TextToSpeechConfigManager.validate_and_set_defaults",
side_effect=_add_key("text_to_speech", 9),
),
patch(
"core.app.apps.chat.app_config_manager.RetrievalResourceConfigManager.validate_and_set_defaults",
side_effect=_add_key("retriever_resource", 10),
),
patch(
"core.app.apps.chat.app_config_manager.SensitiveWordAvoidanceConfigManager.validate_and_set_defaults",
side_effect=_add_key("sensitive_word_avoidance", 11),
),
):
filtered = ChatAppConfigManager.config_validate(tenant_id="t1", config=config)
assert filtered["model"] == 1
assert filtered["inputs"] == 2
assert filtered["file_upload"] == 3
assert filtered["prompt"] == 4
assert filtered["dataset"] == 5
assert filtered["opening_statement"] == 6
assert filtered["suggested_questions_after_answer"] == 7
assert filtered["speech_to_text"] == 8
assert filtered["text_to_speech"] == 9
assert filtered["retriever_resource"] == 10
assert filtered["sensitive_word_avoidance"] == 11

View File

@@ -0,0 +1,280 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from core.app.apps.chat.app_generator import ChatAppGenerator
from core.app.apps.chat.app_runner import ChatAppRunner
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
from core.moderation.base import ModerationError
from dify_graph.model_runtime.errors.invoke import InvokeAuthorizationError
from models.model import AppMode
class DummyGenerateEntity:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class DummyQueueManager:
def __init__(self, *args, **kwargs):
self.published = []
def publish_error(self, error, pub_from):
self.published.append((error, pub_from))
def publish(self, event, pub_from):
self.published.append((event, pub_from))
class TestChatAppGenerator:
def test_generate_requires_query(self):
generator = ChatAppGenerator()
with pytest.raises(ValueError):
generator.generate(
app_model=SimpleNamespace(),
user=SimpleNamespace(),
args={"inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
)
def test_generate_rejects_non_string_query(self):
generator = ChatAppGenerator()
with pytest.raises(ValueError):
generator.generate(
app_model=SimpleNamespace(),
user=SimpleNamespace(),
args={"query": 1, "inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
)
def test_generate_debugger_overrides_model_config(self):
generator = ChatAppGenerator()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
user = SimpleNamespace(id="user-1", session_id="session-1")
args = {"query": "hi", "inputs": {}, "model_config": {"foo": "bar"}}
with (
patch("core.app.apps.chat.app_generator.ConversationService.get_conversation", return_value=None),
patch("core.app.apps.chat.app_generator.ChatAppConfigManager.config_validate", return_value={"x": 1}),
patch(
"core.app.apps.chat.app_generator.ChatAppConfigManager.get_app_config",
return_value=SimpleNamespace(
variables=[], external_data_variables=[], app_model_config_dict={}, app_mode=AppMode.CHAT
),
),
patch("core.app.apps.chat.app_generator.ModelConfigConverter.convert", return_value=SimpleNamespace()),
patch("core.app.apps.chat.app_generator.FileUploadConfigManager.convert", return_value=None),
patch("core.app.apps.chat.app_generator.file_factory.build_from_mappings", return_value=[]),
patch("core.app.apps.chat.app_generator.ChatAppGenerateEntity", DummyGenerateEntity),
patch("core.app.apps.chat.app_generator.TraceQueueManager", return_value=SimpleNamespace()),
patch("core.app.apps.chat.app_generator.MessageBasedAppQueueManager", DummyQueueManager),
patch(
"core.app.apps.chat.app_generator.ChatAppGenerateResponseConverter.convert", return_value={"ok": True}
),
patch.object(ChatAppGenerator, "_get_app_model_config", return_value=SimpleNamespace(to_dict=lambda: {})),
patch.object(ChatAppGenerator, "_prepare_user_inputs", return_value={}),
patch.object(
ChatAppGenerator,
"_init_generate_records",
return_value=(SimpleNamespace(id="c1", mode="chat"), SimpleNamespace(id="m1")),
),
patch.object(ChatAppGenerator, "_handle_response", return_value={"response": True}),
patch("core.app.apps.chat.app_generator.copy_current_request_context", side_effect=lambda f: f),
patch("core.app.apps.chat.app_generator.threading.Thread") as mock_thread,
):
mock_thread.return_value.start.return_value = None
result = generator.generate(app_model, user, args, InvokeFrom.DEBUGGER, streaming=False)
assert result == {"ok": True}
def test_generate_rejects_model_config_override_for_non_debugger(self):
generator = ChatAppGenerator()
with pytest.raises(ValueError):
with (
patch.object(
ChatAppGenerator, "_get_app_model_config", return_value=SimpleNamespace(to_dict=lambda: {})
),
):
generator.generate(
app_model=SimpleNamespace(tenant_id="t1", id="a1", mode=AppMode.CHAT.value),
user=SimpleNamespace(id="u1", session_id="s1"),
args={"query": "hi", "inputs": {}, "model_config": {"foo": "bar"}},
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
)
def test_generate_worker_handles_exceptions(self):
generator = ChatAppGenerator()
queue_manager = DummyQueueManager()
entity = DummyGenerateEntity(task_id="t1", user_id="u1")
with (
patch.object(ChatAppGenerator, "_get_conversation", return_value=SimpleNamespace()),
patch.object(ChatAppGenerator, "_get_message", return_value=SimpleNamespace()),
patch("core.app.apps.chat.app_generator.ChatAppRunner.run", side_effect=InvokeAuthorizationError()),
patch("core.app.apps.chat.app_generator.db.session.close"),
):
generator._generate_worker(
flask_app=Mock(app_context=Mock(return_value=Mock(__enter__=Mock(), __exit__=Mock()))),
application_generate_entity=entity,
queue_manager=queue_manager,
conversation_id="c1",
message_id="m1",
)
assert queue_manager.published
with (
patch.object(ChatAppGenerator, "_get_conversation", return_value=SimpleNamespace()),
patch.object(ChatAppGenerator, "_get_message", return_value=SimpleNamespace()),
patch("core.app.apps.chat.app_generator.ChatAppRunner.run", side_effect=GenerateTaskStoppedError()),
patch("core.app.apps.chat.app_generator.db.session.close"),
):
generator._generate_worker(
flask_app=Mock(app_context=Mock(return_value=Mock(__enter__=Mock(), __exit__=Mock()))),
application_generate_entity=entity,
queue_manager=queue_manager,
conversation_id="c1",
message_id="m1",
)
class TestChatAppRunner:
def test_run_raises_when_app_missing(self):
runner = ChatAppRunner()
app_config = SimpleNamespace(
app_id="app-1", tenant_id="tenant-1", prompt_template=None, external_data_variables=[]
)
app_generate_entity = DummyGenerateEntity(
app_config=app_config,
model_conf=SimpleNamespace(provider_model_bundle=None, model=None, parameters={}, app_model_config_dict={}),
inputs={},
query="hi",
files=[],
file_upload_config=None,
conversation_id=None,
stream=False,
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,
)
with patch("core.app.apps.chat.app_runner.db.session.scalar", return_value=None):
with pytest.raises(ValueError):
runner.run(app_generate_entity, DummyQueueManager(), SimpleNamespace(), SimpleNamespace(id="m1"))
def test_run_moderation_error_direct_output(self):
runner = ChatAppRunner()
app_config = SimpleNamespace(
app_id="app-1",
tenant_id="tenant-1",
prompt_template=None,
external_data_variables=[],
dataset=None,
additional_features=None,
)
app_generate_entity = DummyGenerateEntity(
app_config=app_config,
model_conf=SimpleNamespace(provider_model_bundle=None, model=None, parameters={}, app_model_config_dict={}),
inputs={},
query="hi",
files=[],
file_upload_config=None,
conversation_id=None,
stream=False,
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,
)
with (
patch(
"core.app.apps.chat.app_runner.db.session.scalar",
return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
),
patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])),
patch.object(ChatAppRunner, "moderation_for_inputs", side_effect=ModerationError("blocked")),
patch.object(ChatAppRunner, "direct_output") as mock_direct,
):
runner.run(app_generate_entity, DummyQueueManager(), SimpleNamespace(), SimpleNamespace(id="m1"))
mock_direct.assert_called_once()
def test_run_annotation_reply_short_circuits(self):
runner = ChatAppRunner()
app_config = SimpleNamespace(
app_id="app-1",
tenant_id="tenant-1",
prompt_template=None,
external_data_variables=[],
dataset=None,
additional_features=None,
)
app_generate_entity = DummyGenerateEntity(
app_config=app_config,
model_conf=SimpleNamespace(provider_model_bundle=None, model=None, parameters={}, app_model_config_dict={}),
inputs={},
query="hi",
files=[],
file_upload_config=None,
conversation_id=None,
stream=False,
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,
)
annotation = SimpleNamespace(id="ann-1", content="answer")
with (
patch(
"core.app.apps.chat.app_runner.db.session.scalar",
return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
),
patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])),
patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")),
patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=annotation),
patch.object(ChatAppRunner, "direct_output") as mock_direct,
):
queue_manager = DummyQueueManager()
runner.run(app_generate_entity, queue_manager, SimpleNamespace(), SimpleNamespace(id="m1"))
assert any(isinstance(item[0], QueueAnnotationReplyEvent) for item in queue_manager.published)
mock_direct.assert_called_once()
def test_run_returns_when_hosting_moderation_blocks(self):
runner = ChatAppRunner()
app_config = SimpleNamespace(
app_id="app-1",
tenant_id="tenant-1",
prompt_template=None,
external_data_variables=[],
dataset=None,
additional_features=None,
)
app_generate_entity = DummyGenerateEntity(
app_config=app_config,
model_conf=SimpleNamespace(provider_model_bundle=None, model=None, parameters={}, app_model_config_dict={}),
inputs={},
query="hi",
files=[],
file_upload_config=None,
conversation_id=None,
stream=False,
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,
)
with (
patch(
"core.app.apps.chat.app_runner.db.session.scalar",
return_value=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
),
patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])),
patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")),
patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=None),
patch.object(ChatAppRunner, "check_hosting_moderation", return_value=True),
):
runner.run(app_generate_entity, DummyQueueManager(), SimpleNamespace(), SimpleNamespace(id="m1"))

View File

@@ -0,0 +1,65 @@
from collections.abc import Generator
from core.app.apps.chat.generate_response_converter import ChatAppGenerateResponseConverter
from core.app.entities.task_entities import (
ChatbotAppBlockingResponse,
ChatbotAppStreamResponse,
ErrorStreamResponse,
MessageEndStreamResponse,
MessageStreamResponse,
PingStreamResponse,
)
class TestChatAppGenerateResponseConverter:
def test_convert_blocking_simple_response_metadata(self):
data = ChatbotAppBlockingResponse.Data(
id="msg-1",
mode="chat",
conversation_id="c1",
message_id="m1",
answer="hi",
metadata={"usage": {"total_tokens": 1}},
created_at=1,
)
blocking = ChatbotAppBlockingResponse(task_id="t1", data=data)
response = ChatAppGenerateResponseConverter.convert_blocking_simple_response(blocking)
assert "usage" not in response["metadata"]
def test_convert_stream_responses(self):
def stream() -> Generator[ChatbotAppStreamResponse, None, None]:
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=PingStreamResponse(task_id="t1"),
)
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=MessageStreamResponse(task_id="t1", id="m1", answer="hi"),
)
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=ErrorStreamResponse(task_id="t1", err=ValueError("boom")),
)
yield ChatbotAppStreamResponse(
conversation_id="c1",
message_id="m1",
created_at=1,
stream_response=MessageEndStreamResponse(task_id="t1", id="m1"),
)
full = list(ChatAppGenerateResponseConverter.convert_stream_full_response(stream()))
assert full[0] == "ping"
assert full[1]["event"] == "message"
assert full[2]["event"] == "error"
simple = list(ChatAppGenerateResponseConverter.convert_stream_simple_response(stream()))
assert simple[0] == "ping"
assert simple[-1]["event"] == "message_end"

View File

@@ -0,0 +1,162 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import core.app.apps.completion.app_runner as module
from core.app.apps.completion.app_runner import CompletionAppRunner
from core.moderation.base import ModerationError
from dify_graph.model_runtime.entities.message_entities import ImagePromptMessageContent
@pytest.fixture
def runner():
return CompletionAppRunner()
def _build_app_config(dataset=None, external_tools=None, additional_features=None):
app_config = MagicMock()
app_config.app_id = "app1"
app_config.tenant_id = "tenant"
app_config.prompt_template = MagicMock()
app_config.dataset = dataset
app_config.external_data_variables = external_tools or []
app_config.additional_features = additional_features
app_config.app_model_config_dict = {"file_upload": {"enabled": True}}
return app_config
def _build_generate_entity(app_config, file_upload_config=None):
model_conf = MagicMock(
provider_model_bundle="bundle",
model="model",
parameters={"max_tokens": 10},
stop=["stop"],
)
return SimpleNamespace(
app_config=app_config,
model_conf=model_conf,
inputs={"qvar": "query_from_input"},
query="original_query",
files=[],
file_upload_config=file_upload_config,
stream=True,
user_id="user",
invoke_from=MagicMock(),
)
class TestCompletionAppRunner:
def test_run_app_not_found(self, runner, mocker):
session = mocker.MagicMock()
session.scalar.return_value = None
mocker.patch.object(module.db, "session", session)
app_config = _build_app_config()
app_generate_entity = _build_generate_entity(app_config)
with pytest.raises(ValueError):
runner.run(app_generate_entity, MagicMock(), MagicMock())
def test_run_moderation_error_outputs_direct(self, runner, mocker):
app_record = MagicMock(id="app1", tenant_id="tenant")
session = mocker.MagicMock()
session.scalar.return_value = app_record
mocker.patch.object(module.db, "session", session)
app_config = _build_app_config()
app_generate_entity = _build_generate_entity(app_config)
runner.organize_prompt_messages = MagicMock(return_value=([], None))
runner.moderation_for_inputs = MagicMock(side_effect=ModerationError("blocked"))
runner.direct_output = MagicMock()
runner._handle_invoke_result = MagicMock()
runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg"))
runner.direct_output.assert_called_once()
runner._handle_invoke_result.assert_not_called()
def test_run_hosting_moderation_stops(self, runner, mocker):
app_record = MagicMock(id="app1", tenant_id="tenant")
session = mocker.MagicMock()
session.scalar.return_value = app_record
mocker.patch.object(module.db, "session", session)
app_config = _build_app_config()
app_generate_entity = _build_generate_entity(app_config)
runner.organize_prompt_messages = MagicMock(return_value=([], None))
runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query"))
runner.check_hosting_moderation = MagicMock(return_value=True)
runner._handle_invoke_result = MagicMock()
runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg"))
runner._handle_invoke_result.assert_not_called()
def test_run_dataset_and_external_tools_flow(self, runner, mocker):
app_record = MagicMock(id="app1", tenant_id="tenant")
session = mocker.MagicMock()
session.scalar.return_value = app_record
session.close = MagicMock()
mocker.patch.object(module.db, "session", session)
retrieve_config = MagicMock(query_variable="qvar")
dataset_config = MagicMock(dataset_ids=["ds"], retrieve_config=retrieve_config)
additional_features = MagicMock(show_retrieve_source=True)
app_config = _build_app_config(
dataset=dataset_config,
external_tools=["tool"],
additional_features=additional_features,
)
file_upload_config = MagicMock()
file_upload_config.image_config.detail = ImagePromptMessageContent.DETAIL.HIGH
app_generate_entity = _build_generate_entity(app_config, file_upload_config=file_upload_config)
runner.organize_prompt_messages = MagicMock(side_effect=[(["pm1"], ["stop"]), (["pm2"], ["stop"])])
runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query"))
runner.fill_in_inputs_from_external_data_tools = MagicMock(return_value=app_generate_entity.inputs)
runner.check_hosting_moderation = MagicMock(return_value=False)
runner.recalc_llm_max_tokens = MagicMock()
runner._handle_invoke_result = MagicMock()
dataset_retrieval = MagicMock()
dataset_retrieval.retrieve.return_value = ("ctx", ["file1"])
mocker.patch.object(module, "DatasetRetrieval", return_value=dataset_retrieval)
model_instance = MagicMock()
model_instance.invoke_llm.return_value = "invoke_result"
mocker.patch.object(module, "ModelInstance", return_value=model_instance)
runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg", tenant_id="tenant"))
dataset_retrieval.retrieve.assert_called_once()
assert dataset_retrieval.retrieve.call_args.kwargs["query"] == "query_from_input"
runner._handle_invoke_result.assert_called_once()
def test_run_uses_low_image_detail_default(self, runner, mocker):
app_record = MagicMock(id="app1", tenant_id="tenant")
session = mocker.MagicMock()
session.scalar.return_value = app_record
mocker.patch.object(module.db, "session", session)
app_config = _build_app_config()
app_generate_entity = _build_generate_entity(app_config, file_upload_config=None)
runner.organize_prompt_messages = MagicMock(return_value=([], None))
runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query"))
runner.check_hosting_moderation = MagicMock(return_value=True)
runner.run(app_generate_entity, MagicMock(), MagicMock(id="msg"))
assert (
runner.organize_prompt_messages.call_args.kwargs["image_detail_config"]
== ImagePromptMessageContent.DETAIL.LOW
)

View File

@@ -0,0 +1,122 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import core.app.apps.completion.app_config_manager as module
from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom
from core.app.apps.completion.app_config_manager import CompletionAppConfigManager
from models.model import AppMode
class TestCompletionAppConfigManager:
def test_get_app_config_with_override(self, mocker):
app_model = MagicMock(tenant_id="tenant", id="app1", mode=AppMode.COMPLETION.value)
app_model_config = MagicMock(id="cfg1")
app_model_config.to_dict.return_value = {"model": {"provider": "x"}}
override_config = {"model": {"provider": "override"}}
mocker.patch.object(module.ModelConfigManager, "convert", return_value="model")
mocker.patch.object(module.PromptTemplateConfigManager, "convert", return_value="prompt")
mocker.patch.object(module.SensitiveWordAvoidanceConfigManager, "convert", return_value="moderation")
mocker.patch.object(module.DatasetConfigManager, "convert", return_value="dataset")
mocker.patch.object(CompletionAppConfigManager, "convert_features", return_value="features")
mocker.patch.object(module.BasicVariablesConfigManager, "convert", return_value=(["v1"], ["ext1"]))
mocker.patch.object(module, "CompletionAppConfig", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
result = CompletionAppConfigManager.get_app_config(
app_model=app_model,
app_model_config=app_model_config,
override_config_dict=override_config,
)
assert result.app_model_config_from == EasyUIBasedAppModelConfigFrom.ARGS
assert result.app_model_config_dict == override_config
assert result.variables == ["v1"]
assert result.external_data_variables == ["ext1"]
assert result.app_mode == AppMode.COMPLETION
def test_get_app_config_without_override_uses_model_config(self, mocker):
app_model = MagicMock(tenant_id="tenant", id="app1", mode=AppMode.COMPLETION.value)
app_model_config = MagicMock(id="cfg1")
app_model_config.to_dict.return_value = {"model": {"provider": "x"}}
mocker.patch.object(module.ModelConfigManager, "convert", return_value="model")
mocker.patch.object(module.PromptTemplateConfigManager, "convert", return_value="prompt")
mocker.patch.object(module.SensitiveWordAvoidanceConfigManager, "convert", return_value="moderation")
mocker.patch.object(module.DatasetConfigManager, "convert", return_value="dataset")
mocker.patch.object(CompletionAppConfigManager, "convert_features", return_value="features")
mocker.patch.object(module.BasicVariablesConfigManager, "convert", return_value=([], []))
mocker.patch.object(module, "CompletionAppConfig", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
result = CompletionAppConfigManager.get_app_config(app_model=app_model, app_model_config=app_model_config)
assert result.app_model_config_from == EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG
assert result.app_model_config_dict == {"model": {"provider": "x"}}
def test_config_validate_filters_related_keys(self, mocker):
config = {
"model": {"provider": "x"},
"variables": ["v"],
"file_upload": {"enabled": True},
"prompt": {"template": "t"},
"dataset": {"enabled": True},
"tts": {"enabled": True},
"more_like_this": {"enabled": True},
"moderation": {"enabled": True},
"extra": "drop",
}
mocker.patch.object(
module.ModelConfigManager,
"validate_and_set_defaults",
return_value=(config, ["model"]),
)
mocker.patch.object(
module.BasicVariablesConfigManager,
"validate_and_set_defaults",
return_value=(config, ["variables"]),
)
mocker.patch.object(
module.FileUploadConfigManager,
"validate_and_set_defaults",
return_value=(config, ["file_upload"]),
)
mocker.patch.object(
module.PromptTemplateConfigManager,
"validate_and_set_defaults",
return_value=(config, ["prompt"]),
)
mocker.patch.object(
module.DatasetConfigManager,
"validate_and_set_defaults",
return_value=(config, ["dataset"]),
)
mocker.patch.object(
module.TextToSpeechConfigManager,
"validate_and_set_defaults",
return_value=(config, ["tts"]),
)
mocker.patch.object(
module.MoreLikeThisConfigManager,
"validate_and_set_defaults",
return_value=(config, ["more_like_this"]),
)
mocker.patch.object(
module.SensitiveWordAvoidanceConfigManager,
"validate_and_set_defaults",
return_value=(config, ["moderation"]),
)
filtered = CompletionAppConfigManager.config_validate("tenant", config)
assert "extra" not in filtered
assert set(filtered.keys()) == {
"model",
"variables",
"file_upload",
"prompt",
"dataset",
"tts",
"more_like_this",
"moderation",
}

View File

@@ -0,0 +1,321 @@
import contextlib
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from pydantic import ValidationError
import core.app.apps.completion.app_generator as module
from core.app.apps.completion.app_generator import CompletionAppGenerator
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom
from dify_graph.model_runtime.errors.invoke import InvokeAuthorizationError
from services.errors.app import MoreLikeThisDisabledError
from services.errors.message import MessageNotExistsError
@pytest.fixture
def generator(mocker):
gen = CompletionAppGenerator()
mocker.patch.object(module, "copy_current_request_context", side_effect=lambda fn: fn)
flask_app = MagicMock()
flask_app.app_context.return_value = contextlib.nullcontext()
mocker.patch.object(module, "current_app", MagicMock(_get_current_object=MagicMock(return_value=flask_app)))
thread = MagicMock()
mocker.patch.object(module.threading, "Thread", return_value=thread)
mocker.patch.object(module, "MessageBasedAppQueueManager", return_value=MagicMock())
mocker.patch.object(module, "TraceQueueManager", return_value=MagicMock())
mocker.patch.object(module, "CompletionAppGenerateEntity", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
return gen
def _build_app_model():
return MagicMock(tenant_id="tenant", id="app1", mode="completion")
def _build_user():
return MagicMock(id="user", session_id="session")
def _build_app_model_config():
config = MagicMock(id="cfg")
config.to_dict.return_value = {"model": {"provider": "x"}}
return config
class TestCompletionAppGenerator:
def test_generate_invalid_query_type(self, generator):
with pytest.raises(ValueError):
generator.generate(
app_model=_build_app_model(),
user=_build_user(),
args={"query": 123, "inputs": {}, "files": []},
invoke_from=InvokeFrom.WEB_APP,
streaming=True,
)
def test_generate_override_not_debugger(self, generator):
with pytest.raises(ValueError):
generator.generate(
app_model=_build_app_model(),
user=_build_user(),
args={"query": "q", "inputs": {}, "files": [], "model_config": {}},
invoke_from=InvokeFrom.WEB_APP,
streaming=False,
)
def test_generate_success_no_file_config(self, generator, mocker):
app_model_config = _build_app_model_config()
mocker.patch.object(generator, "_get_app_model_config", return_value=app_model_config)
mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=None)
mocker.patch.object(module.file_factory, "build_from_mappings")
app_config = MagicMock(variables=["v"], to_dict=MagicMock(return_value={}))
mocker.patch.object(module.CompletionAppConfigManager, "get_app_config", return_value=app_config)
mocker.patch.object(module.ModelConfigConverter, "convert", return_value=MagicMock())
mocker.patch.object(generator, "_prepare_user_inputs", return_value={"k": "v"})
conversation = MagicMock(id="conv", mode="completion")
message = MagicMock(id="msg")
mocker.patch.object(generator, "_init_generate_records", return_value=(conversation, message))
mocker.patch.object(generator, "_handle_response", return_value="response")
mocker.patch.object(module.CompletionAppGenerateResponseConverter, "convert", return_value="converted")
result = generator.generate(
app_model=_build_app_model(),
user=_build_user(),
args={"query": "q", "inputs": {"a": 1}, "files": []},
invoke_from=InvokeFrom.WEB_APP,
streaming=True,
)
assert result == "converted"
module.file_factory.build_from_mappings.assert_not_called()
def test_generate_success_with_files(self, generator, mocker):
app_model_config = _build_app_model_config()
mocker.patch.object(generator, "_get_app_model_config", return_value=app_model_config)
file_extra_config = MagicMock()
mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=file_extra_config)
mocker.patch.object(module.file_factory, "build_from_mappings", return_value=["file1"])
app_config = MagicMock(variables=["v"], to_dict=MagicMock(return_value={}))
mocker.patch.object(module.CompletionAppConfigManager, "get_app_config", return_value=app_config)
mocker.patch.object(module.ModelConfigConverter, "convert", return_value=MagicMock())
mocker.patch.object(generator, "_prepare_user_inputs", return_value={"k": "v"})
conversation = MagicMock(id="conv", mode="completion")
message = MagicMock(id="msg")
mocker.patch.object(generator, "_init_generate_records", return_value=(conversation, message))
mocker.patch.object(generator, "_handle_response", return_value="response")
mocker.patch.object(module.CompletionAppGenerateResponseConverter, "convert", return_value="converted")
result = generator.generate(
app_model=_build_app_model(),
user=_build_user(),
args={"query": "q", "inputs": {"a": 1}, "files": [{"id": "f"}]},
invoke_from=InvokeFrom.WEB_APP,
streaming=False,
)
assert result == "converted"
module.file_factory.build_from_mappings.assert_called_once()
def test_generate_override_model_config_debugger(self, generator, mocker):
app_model_config = _build_app_model_config()
mocker.patch.object(generator, "_get_app_model_config", return_value=app_model_config)
override_config = {"model": {"provider": "override"}}
mocker.patch.object(module.CompletionAppConfigManager, "config_validate", return_value=override_config)
app_config = MagicMock(variables=["v"], to_dict=MagicMock(return_value={}))
get_app_config = mocker.patch.object(
module.CompletionAppConfigManager,
"get_app_config",
return_value=app_config,
)
mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=None)
mocker.patch.object(module.ModelConfigConverter, "convert", return_value=MagicMock())
mocker.patch.object(generator, "_prepare_user_inputs", return_value={"k": "v"})
mocker.patch.object(
generator,
"_init_generate_records",
return_value=(MagicMock(id="conv", mode="completion"), MagicMock(id="msg")),
)
mocker.patch.object(generator, "_handle_response", return_value="response")
mocker.patch.object(module.CompletionAppGenerateResponseConverter, "convert", return_value="converted")
generator.generate(
app_model=_build_app_model(),
user=_build_user(),
args={"query": "q", "inputs": {}, "files": [], "model_config": override_config},
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
)
assert get_app_config.call_args.kwargs["override_config_dict"] == override_config
def test_generate_more_like_this_message_not_found(self, generator, mocker):
session = mocker.MagicMock()
session.scalar.return_value = None
mocker.patch.object(module.db, "session", session)
with pytest.raises(MessageNotExistsError):
generator.generate_more_like_this(
app_model=_build_app_model(),
message_id="msg",
user=_build_user(),
invoke_from=InvokeFrom.WEB_APP,
)
def test_generate_more_like_this_disabled(self, generator, mocker):
app_model = _build_app_model()
app_model.app_model_config = MagicMock(more_like_this=False, more_like_this_dict={"enabled": False})
message = MagicMock()
session = mocker.MagicMock()
session.scalar.return_value = message
mocker.patch.object(module.db, "session", session)
with pytest.raises(MoreLikeThisDisabledError):
generator.generate_more_like_this(
app_model=app_model,
message_id="msg",
user=_build_user(),
invoke_from=InvokeFrom.WEB_APP,
)
def test_generate_more_like_this_app_model_config_missing(self, generator, mocker):
app_model = _build_app_model()
app_model.app_model_config = None
message = MagicMock()
session = mocker.MagicMock()
session.scalar.return_value = message
mocker.patch.object(module.db, "session", session)
with pytest.raises(MoreLikeThisDisabledError):
generator.generate_more_like_this(
app_model=app_model,
message_id="msg",
user=_build_user(),
invoke_from=InvokeFrom.WEB_APP,
)
def test_generate_more_like_this_message_config_none(self, generator, mocker):
app_model = _build_app_model()
app_model.app_model_config = MagicMock(more_like_this=True, more_like_this_dict={"enabled": True})
message = MagicMock(app_model_config=None)
session = mocker.MagicMock()
session.scalar.return_value = message
mocker.patch.object(module.db, "session", session)
with pytest.raises(ValueError):
generator.generate_more_like_this(
app_model=app_model,
message_id="msg",
user=_build_user(),
invoke_from=InvokeFrom.WEB_APP,
)
def test_generate_more_like_this_success(self, generator, mocker):
app_model = _build_app_model()
app_model.app_model_config = MagicMock(more_like_this=True, more_like_this_dict={"enabled": True})
message = MagicMock()
message.message_files = [{"id": "f"}]
message.inputs = {"a": 1}
message.query = "q"
app_model_config = MagicMock()
app_model_config.to_dict.return_value = {
"model": {"completion_params": {"temperature": 0.1}},
"file_upload": {"enabled": True},
}
message.app_model_config = app_model_config
session = mocker.MagicMock()
session.scalar.return_value = message
mocker.patch.object(module.db, "session", session)
file_extra_config = MagicMock()
mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=file_extra_config)
mocker.patch.object(module.file_factory, "build_from_mappings", return_value=["file1"])
app_config = MagicMock(variables=["v"], to_dict=MagicMock(return_value={}))
get_app_config = mocker.patch.object(
module.CompletionAppConfigManager,
"get_app_config",
return_value=app_config,
)
mocker.patch.object(module.ModelConfigConverter, "convert", return_value=MagicMock())
mocker.patch.object(
generator,
"_init_generate_records",
return_value=(MagicMock(id="conv", mode="completion"), MagicMock(id="msg")),
)
mocker.patch.object(generator, "_handle_response", return_value="response")
mocker.patch.object(module.CompletionAppGenerateResponseConverter, "convert", return_value="converted")
result = generator.generate_more_like_this(
app_model=app_model,
message_id="msg",
user=_build_user(),
invoke_from=InvokeFrom.WEB_APP,
stream=True,
)
assert result == "converted"
override_dict = get_app_config.call_args.kwargs["override_config_dict"]
assert override_dict["model"]["completion_params"]["temperature"] == 0.9
@pytest.mark.parametrize(
("error", "should_publish"),
[
(GenerateTaskStoppedError(), False),
(InvokeAuthorizationError("bad"), True),
(
ValidationError.from_exception_data(
"Model",
[{"type": "missing", "loc": ("x",), "msg": "Field required", "input": {}}],
),
True,
),
(ValueError("bad"), True),
(RuntimeError("boom"), True),
],
)
def test_generate_worker_error_handling(self, generator, mocker, error, should_publish):
flask_app = MagicMock()
flask_app.app_context.return_value = contextlib.nullcontext()
session = mocker.MagicMock()
mocker.patch.object(module.db, "session", session)
mocker.patch.object(generator, "_get_message", return_value=MagicMock())
runner_instance = MagicMock()
runner_instance.run.side_effect = error
mocker.patch.object(module, "CompletionAppRunner", return_value=runner_instance)
queue_manager = MagicMock()
generator._generate_worker(
flask_app=flask_app,
application_generate_entity=MagicMock(),
queue_manager=queue_manager,
message_id="msg",
)
assert queue_manager.publish_error.called is should_publish

View File

@@ -0,0 +1,153 @@
from collections.abc import Generator
from core.app.apps.completion.generate_response_converter import CompletionAppGenerateResponseConverter
from core.app.entities.task_entities import (
AppStreamResponse,
CompletionAppBlockingResponse,
CompletionAppStreamResponse,
ErrorStreamResponse,
MessageEndStreamResponse,
MessageStreamResponse,
PingStreamResponse,
)
class TestCompletionAppGenerateResponseConverter:
def test_convert_blocking_full_response(self):
blocking = CompletionAppBlockingResponse(
task_id="task",
data=CompletionAppBlockingResponse.Data(
id="id",
mode="completion",
message_id="msg",
answer="answer",
metadata={"k": "v"},
created_at=123,
),
)
result = CompletionAppGenerateResponseConverter.convert_blocking_full_response(blocking)
assert result["event"] == "message"
assert result["task_id"] == "task"
assert result["message_id"] == "msg"
assert result["answer"] == "answer"
assert result["metadata"] == {"k": "v"}
def test_convert_blocking_simple_response_metadata_simplified(self):
metadata = {
"retriever_resources": [
{
"segment_id": "s",
"position": 1,
"document_name": "doc",
"score": 0.9,
"content": "c",
"summary": "sum",
"extra": "x",
}
],
"annotation_reply": {"a": 1},
"usage": {"t": 2},
}
blocking = CompletionAppBlockingResponse(
task_id="task",
data=CompletionAppBlockingResponse.Data(
id="id",
mode="completion",
message_id="msg",
answer="answer",
metadata=metadata,
created_at=123,
),
)
result = CompletionAppGenerateResponseConverter.convert_blocking_simple_response(blocking)
assert "annotation_reply" not in result["metadata"]
assert "usage" not in result["metadata"]
assert result["metadata"]["retriever_resources"][0]["segment_id"] == "s"
assert "extra" not in result["metadata"]["retriever_resources"][0]
def test_convert_blocking_simple_response_metadata_not_dict(self):
data = CompletionAppBlockingResponse.Data.model_construct(
id="id",
mode="completion",
message_id="msg",
answer="answer",
metadata="bad",
created_at=123,
)
blocking = CompletionAppBlockingResponse.model_construct(task_id="task", data=data)
result = CompletionAppGenerateResponseConverter.convert_blocking_simple_response(blocking)
assert result["metadata"] == {}
def test_convert_stream_full_response(self):
def stream() -> Generator[AppStreamResponse, None, None]:
yield CompletionAppStreamResponse(
stream_response=PingStreamResponse(task_id="t"),
message_id="m",
created_at=1,
)
yield CompletionAppStreamResponse(
stream_response=ErrorStreamResponse(task_id="t", err=ValueError("bad")),
message_id="m",
created_at=2,
)
yield CompletionAppStreamResponse(
stream_response=MessageStreamResponse(task_id="t", id="1", answer="ok"),
message_id="m",
created_at=3,
)
result = list(CompletionAppGenerateResponseConverter.convert_stream_full_response(stream()))
assert result[0] == "ping"
assert result[1]["event"] == "error"
assert result[1]["code"] == "invalid_param"
assert result[2]["event"] == "message"
def test_convert_stream_simple_response(self):
def stream() -> Generator[AppStreamResponse, None, None]:
yield CompletionAppStreamResponse(
stream_response=PingStreamResponse(task_id="t"),
message_id="m",
created_at=1,
)
yield CompletionAppStreamResponse(
stream_response=MessageEndStreamResponse(
task_id="t",
id="end",
metadata={
"retriever_resources": [
{
"segment_id": "s",
"position": 1,
"document_name": "doc",
"score": 0.9,
"content": "c",
"summary": "sum",
}
],
"annotation_reply": {"a": 1},
"usage": {"t": 2},
},
),
message_id="m",
created_at=2,
)
yield CompletionAppStreamResponse(
stream_response=ErrorStreamResponse(task_id="t", err=ValueError("bad")),
message_id="m",
created_at=3,
)
result = list(CompletionAppGenerateResponseConverter.convert_stream_simple_response(stream()))
assert result[0] == "ping"
assert result[1]["event"] == "message_end"
assert "annotation_reply" not in result[1]["metadata"]
assert "usage" not in result[1]["metadata"]
assert result[2]["event"] == "error"

View File

@@ -0,0 +1,55 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import core.app.apps.pipeline.pipeline_config_manager as module
from core.app.apps.pipeline.pipeline_config_manager import PipelineConfigManager
from models.model import AppMode
def test_get_pipeline_config(mocker):
pipeline = MagicMock(tenant_id="tenant", id="pipe1")
workflow = MagicMock(id="wf1")
mocker.patch.object(
module.WorkflowVariablesConfigManager,
"convert_rag_pipeline_variable",
return_value=["var1"],
)
mocker.patch.object(module, "PipelineConfig", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
result = PipelineConfigManager.get_pipeline_config(pipeline=pipeline, workflow=workflow, start_node_id="start")
assert result.tenant_id == "tenant"
assert result.app_id == "pipe1"
assert result.workflow_id == "wf1"
assert result.app_mode == AppMode.RAG_PIPELINE
assert result.rag_pipeline_variables == ["var1"]
def test_config_validate_filters_related_keys(mocker):
config = {
"file_upload": {"enabled": True},
"tts": {"enabled": True},
"moderation": {"enabled": True},
"extra": "drop",
}
mocker.patch.object(
module.FileUploadConfigManager,
"validate_and_set_defaults",
return_value=(config, ["file_upload"]),
)
mocker.patch.object(
module.TextToSpeechConfigManager,
"validate_and_set_defaults",
return_value=(config, ["tts"]),
)
mocker.patch.object(
module.SensitiveWordAvoidanceConfigManager,
"validate_and_set_defaults",
return_value=(config, ["moderation"]),
)
filtered = PipelineConfigManager.config_validate("tenant", config)
assert set(filtered.keys()) == {"file_upload", "tts", "moderation"}

View File

@@ -0,0 +1,111 @@
from collections.abc import Generator
from core.app.apps.pipeline.generate_response_converter import WorkflowAppGenerateResponseConverter
from core.app.entities.task_entities import (
AppStreamResponse,
ErrorStreamResponse,
NodeFinishStreamResponse,
NodeStartStreamResponse,
PingStreamResponse,
WorkflowAppBlockingResponse,
WorkflowAppStreamResponse,
)
from dify_graph.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
def test_convert_blocking_full_and_simple_response():
blocking = WorkflowAppBlockingResponse(
task_id="task",
workflow_run_id="run",
data=WorkflowAppBlockingResponse.Data(
id="id",
workflow_id="wf",
status=WorkflowExecutionStatus.SUCCEEDED,
outputs={"k": "v"},
error=None,
elapsed_time=0.1,
total_tokens=10,
total_steps=1,
created_at=1,
finished_at=2,
),
)
full = WorkflowAppGenerateResponseConverter.convert_blocking_full_response(blocking)
simple = WorkflowAppGenerateResponseConverter.convert_blocking_simple_response(blocking)
assert full == simple
assert full["workflow_run_id"] == "run"
assert full["data"]["status"] == WorkflowExecutionStatus.SUCCEEDED
def test_convert_stream_full_response():
def stream() -> Generator[AppStreamResponse, None, None]:
yield WorkflowAppStreamResponse(
stream_response=PingStreamResponse(task_id="t"),
workflow_run_id="run",
)
yield WorkflowAppStreamResponse(
stream_response=ErrorStreamResponse(task_id="t", err=ValueError("bad")),
workflow_run_id="run",
)
result = list(WorkflowAppGenerateResponseConverter.convert_stream_full_response(stream()))
assert result[0] == "ping"
assert result[1]["event"] == "error"
assert result[1]["code"] == "invalid_param"
def test_convert_stream_simple_response_node_ignore_details():
node_start = NodeStartStreamResponse(
task_id="t",
workflow_run_id="run",
data=NodeStartStreamResponse.Data(
id="nid",
node_id="node",
node_type="type",
title="Title",
index=1,
predecessor_node_id=None,
inputs={"a": 1},
inputs_truncated=False,
created_at=1,
),
)
node_finish = NodeFinishStreamResponse(
task_id="t",
workflow_run_id="run",
data=NodeFinishStreamResponse.Data(
id="nid",
node_id="node",
node_type="type",
title="Title",
index=1,
predecessor_node_id=None,
inputs={"a": 1},
inputs_truncated=False,
process_data=None,
process_data_truncated=False,
outputs={"b": 2},
outputs_truncated=False,
status=WorkflowNodeExecutionStatus.SUCCEEDED,
error=None,
elapsed_time=0.1,
execution_metadata=None,
created_at=1,
finished_at=2,
files=[],
),
)
def stream() -> Generator[AppStreamResponse, None, None]:
yield WorkflowAppStreamResponse(stream_response=node_start, workflow_run_id="run")
yield WorkflowAppStreamResponse(stream_response=node_finish, workflow_run_id="run")
result = list(WorkflowAppGenerateResponseConverter.convert_stream_simple_response(stream()))
assert result[0]["event"] == "node_started"
assert result[0]["data"]["inputs"] is None
assert result[1]["event"] == "node_finished"
assert result[1]["data"]["inputs"] is None

View File

@@ -0,0 +1,699 @@
import contextlib
from types import SimpleNamespace
from unittest.mock import MagicMock, PropertyMock
import pytest
import core.app.apps.pipeline.pipeline_generator as module
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.datasource.entities.datasource_entities import DatasourceProviderType
class FakeRagPipelineGenerateEntity(SimpleNamespace):
class SingleIterationRunEntity(SimpleNamespace):
pass
class SingleLoopRunEntity(SimpleNamespace):
pass
def model_dump(self):
return dict(self.__dict__)
@pytest.fixture
def generator(mocker):
gen = module.PipelineGenerator()
mocker.patch.object(module, "RagPipelineGenerateEntity", FakeRagPipelineGenerateEntity)
mocker.patch.object(module, "RagPipelineInvokeEntity", side_effect=lambda **kwargs: kwargs)
mocker.patch.object(module.contexts, "plugin_tool_providers", SimpleNamespace(set=MagicMock()))
mocker.patch.object(module.contexts, "plugin_tool_providers_lock", SimpleNamespace(set=MagicMock()))
return gen
def _build_pipeline_dataset():
return SimpleNamespace(
id="ds",
name="dataset",
description="desc",
chunk_structure="chunk",
built_in_field_enabled=True,
tenant_id="tenant",
)
def _build_pipeline():
pipeline = MagicMock(tenant_id="tenant", id="pipe")
pipeline.retrieve_dataset.return_value = _build_pipeline_dataset()
return pipeline
def _build_workflow():
return MagicMock(id="wf", graph_dict={"nodes": [], "edges": []}, tenant_id="tenant")
def _build_user():
return MagicMock(id="user", name="User", session_id="session")
def _build_args():
return {
"inputs": {"k": "v"},
"start_node_id": "start",
"datasource_type": DatasourceProviderType.LOCAL_FILE.value,
"datasource_info_list": [{"name": "file"}],
}
def _patch_session(mocker, session):
mocker.patch.object(module, "Session", return_value=session)
mocker.patch.object(type(module.db), "engine", new_callable=PropertyMock, return_value=MagicMock())
def _dummy_preserve(*args, **kwargs):
return contextlib.nullcontext()
class DummySession:
def __init__(self):
self.scalar = MagicMock()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def test_generate_dataset_missing(generator, mocker):
pipeline = _build_pipeline()
pipeline.retrieve_dataset.return_value = None
session = DummySession()
_patch_session(mocker, session)
with pytest.raises(ValueError):
generator.generate(
pipeline=pipeline,
workflow=_build_workflow(),
user=_build_user(),
args=_build_args(),
invoke_from=InvokeFrom.WEB_APP,
streaming=False,
)
def test_generate_debugger_calls_generate(generator, mocker):
pipeline = _build_pipeline()
workflow = _build_workflow()
session = DummySession()
_patch_session(mocker, session)
mocker.patch.object(
generator,
"_format_datasource_info_list",
return_value=[{"name": "file"}],
)
mocker.patch.object(
module.PipelineConfigManager,
"get_pipeline_config",
return_value=SimpleNamespace(app_id="pipe", rag_pipeline_variables=[]),
)
mocker.patch.object(generator, "_prepare_user_inputs", return_value={"k": "v"})
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_node_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(generator, "_generate", return_value={"result": "ok"})
result = generator.generate(
pipeline=pipeline,
workflow=workflow,
user=_build_user(),
args=_build_args(),
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
)
assert result == {"result": "ok"}
def test_generate_published_pipeline_creates_documents_and_delay(generator, mocker):
pipeline = _build_pipeline()
workflow = _build_workflow()
session = DummySession()
_patch_session(mocker, session)
datasource_info_list = [{"name": "file1"}, {"name": "file2"}]
mocker.patch.object(
generator,
"_format_datasource_info_list",
return_value=datasource_info_list,
)
mocker.patch.object(
module.PipelineConfigManager,
"get_pipeline_config",
return_value=SimpleNamespace(app_id="pipe", rag_pipeline_variables=[]),
)
mocker.patch.object(generator, "_prepare_user_inputs", return_value={"k": "v"})
mocker.patch("services.dataset_service.DocumentService.get_documents_position", return_value=1)
document1 = SimpleNamespace(
id="doc1",
position=1,
data_source_type=DatasourceProviderType.LOCAL_FILE,
data_source_info="{}",
name="file1",
indexing_status="",
error=None,
enabled=True,
)
document2 = SimpleNamespace(
id="doc2",
position=2,
data_source_type=DatasourceProviderType.LOCAL_FILE,
data_source_info="{}",
name="file2",
indexing_status="",
error=None,
enabled=True,
)
mocker.patch.object(generator, "_build_document", side_effect=[document1, document2])
mocker.patch.object(module, "DocumentPipelineExecutionLog", return_value=MagicMock())
db_session = MagicMock()
mocker.patch.object(module.db, "session", db_session)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_node_execution_repository",
return_value=MagicMock(),
)
task_proxy = MagicMock()
mocker.patch.object(module, "RagPipelineTaskProxy", return_value=task_proxy)
result = generator.generate(
pipeline=pipeline,
workflow=workflow,
user=_build_user(),
args=_build_args(),
invoke_from=InvokeFrom.PUBLISHED_PIPELINE,
streaming=False,
)
assert result["batch"]
assert len(result["documents"]) == 2
task_proxy.delay.assert_called_once()
def test_generate_is_retry_calls_generate(generator, mocker):
pipeline = _build_pipeline()
workflow = _build_workflow()
session = DummySession()
_patch_session(mocker, session)
mocker.patch.object(
generator,
"_format_datasource_info_list",
return_value=[{"name": "file"}],
)
mocker.patch.object(
module.PipelineConfigManager,
"get_pipeline_config",
return_value=SimpleNamespace(app_id="pipe", rag_pipeline_variables=[]),
)
mocker.patch.object(generator, "_prepare_user_inputs", return_value={"k": "v"})
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_node_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(generator, "_generate", return_value={"result": "ok"})
result = generator.generate(
pipeline=pipeline,
workflow=workflow,
user=_build_user(),
args=_build_args(),
invoke_from=InvokeFrom.PUBLISHED_PIPELINE,
streaming=True,
is_retry=True,
)
assert result == {"result": "ok"}
def test_generate_worker_handles_errors(generator, mocker):
flask_app = MagicMock()
flask_app.app_context.return_value = contextlib.nullcontext()
mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve)
mocker.patch.object(module.db, "session", MagicMock(close=MagicMock()))
mocker.patch.object(type(module.db), "engine", new_callable=PropertyMock, return_value=MagicMock())
application_generate_entity = FakeRagPipelineGenerateEntity(
app_config=SimpleNamespace(tenant_id="tenant", app_id="pipe", workflow_id="wf"),
invoke_from=InvokeFrom.WEB_APP,
user_id="user",
)
session = DummySession()
session.scalar.side_effect = [MagicMock(), MagicMock(session_id="session")]
_patch_session(mocker, session)
runner_instance = MagicMock()
runner_instance.run.side_effect = ValueError("bad")
mocker.patch.object(module, "PipelineRunner", return_value=runner_instance)
queue_manager = MagicMock()
generator._generate_worker(
flask_app=flask_app,
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
context=contextlib.nullcontext(),
variable_loader=MagicMock(),
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
)
queue_manager.publish_error.assert_called_once()
def test_generate_worker_sets_system_user_id_for_external_call(generator, mocker):
flask_app = MagicMock()
flask_app.app_context.return_value = contextlib.nullcontext()
mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve)
mocker.patch.object(module.db, "session", MagicMock(close=MagicMock()))
mocker.patch.object(type(module.db), "engine", new_callable=PropertyMock, return_value=MagicMock())
application_generate_entity = FakeRagPipelineGenerateEntity(
app_config=SimpleNamespace(tenant_id="tenant", app_id="pipe", workflow_id="wf"),
invoke_from=InvokeFrom.WEB_APP,
user_id="user",
)
session = DummySession()
session.scalar.side_effect = [MagicMock(), MagicMock(session_id="session")]
_patch_session(mocker, session)
runner_instance = MagicMock()
mocker.patch.object(module, "PipelineRunner", return_value=runner_instance)
generator._generate_worker(
flask_app=flask_app,
application_generate_entity=application_generate_entity,
queue_manager=MagicMock(),
context=contextlib.nullcontext(),
variable_loader=MagicMock(),
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
)
assert module.PipelineRunner.call_args.kwargs["system_user_id"] == "session"
def test_generate_raises_when_workflow_not_found(generator, mocker):
flask_app = MagicMock()
mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve)
session = MagicMock()
session.query.return_value.where.return_value.first.return_value = None
mocker.patch.object(module.db, "session", session)
with pytest.raises(ValueError):
generator._generate(
flask_app=flask_app,
context=contextlib.nullcontext(),
pipeline=_build_pipeline(),
workflow_id="wf",
user=_build_user(),
application_generate_entity=FakeRagPipelineGenerateEntity(
task_id="t",
app_config=SimpleNamespace(app_id="pipe"),
user_id="user",
invoke_from=InvokeFrom.DEBUGGER,
),
invoke_from=InvokeFrom.DEBUGGER,
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
streaming=True,
)
def test_generate_success_returns_converted(generator, mocker):
flask_app = MagicMock()
mocker.patch.object(module, "preserve_flask_contexts", _dummy_preserve)
workflow = MagicMock(id="wf", tenant_id="tenant", app_id="pipe", graph_dict={})
session = MagicMock()
session.query.return_value.where.return_value.first.return_value = workflow
mocker.patch.object(module.db, "session", session)
queue_manager = MagicMock()
mocker.patch.object(module, "PipelineQueueManager", return_value=queue_manager)
worker_thread = MagicMock()
mocker.patch.object(module.threading, "Thread", return_value=worker_thread)
mocker.patch.object(generator, "_get_draft_var_saver_factory", return_value=MagicMock())
mocker.patch.object(generator, "_handle_response", return_value="response")
mocker.patch.object(module.WorkflowAppGenerateResponseConverter, "convert", return_value="converted")
result = generator._generate(
flask_app=flask_app,
context=contextlib.nullcontext(),
pipeline=_build_pipeline(),
workflow_id="wf",
user=_build_user(),
application_generate_entity=FakeRagPipelineGenerateEntity(
task_id="t",
app_config=SimpleNamespace(app_id="pipe"),
user_id="user",
invoke_from=InvokeFrom.DEBUGGER,
),
invoke_from=InvokeFrom.DEBUGGER,
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
streaming=True,
)
assert result == "converted"
def test_single_iteration_generate_validates_inputs(generator, mocker):
with pytest.raises(ValueError):
generator.single_iteration_generate(_build_pipeline(), _build_workflow(), "", _build_user(), {})
with pytest.raises(ValueError):
generator.single_iteration_generate(
_build_pipeline(), _build_workflow(), "node", _build_user(), {"inputs": None}
)
def test_single_iteration_generate_dataset_required(generator, mocker):
pipeline = _build_pipeline()
pipeline.retrieve_dataset.return_value = None
session = DummySession()
_patch_session(mocker, session)
with pytest.raises(ValueError):
generator.single_iteration_generate(
pipeline,
_build_workflow(),
"node",
_build_user(),
{"inputs": {"a": 1}},
)
def test_single_iteration_generate_success(generator, mocker):
pipeline = _build_pipeline()
session = DummySession()
_patch_session(mocker, session)
mocker.patch.object(
module.PipelineConfigManager,
"get_pipeline_config",
return_value=SimpleNamespace(app_id="pipe", tenant_id="tenant"),
)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_node_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(module.db, "session", MagicMock(return_value=MagicMock()))
mocker.patch.object(module, "WorkflowDraftVariableService", return_value=MagicMock())
mocker.patch.object(module, "DraftVarLoader", return_value=MagicMock())
mocker.patch.object(generator, "_generate", return_value={"ok": True})
result = generator.single_iteration_generate(
pipeline,
_build_workflow(),
"node",
_build_user(),
{"inputs": {"a": 1}},
streaming=False,
)
assert result == {"ok": True}
def test_single_loop_generate_success(generator, mocker):
pipeline = _build_pipeline()
session = DummySession()
_patch_session(mocker, session)
mocker.patch.object(
module.PipelineConfigManager,
"get_pipeline_config",
return_value=SimpleNamespace(app_id="pipe", tenant_id="tenant"),
)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(
module.DifyCoreRepositoryFactory,
"create_workflow_node_execution_repository",
return_value=MagicMock(),
)
mocker.patch.object(module.db, "session", MagicMock(return_value=MagicMock()))
mocker.patch.object(module, "WorkflowDraftVariableService", return_value=MagicMock())
mocker.patch.object(module, "DraftVarLoader", return_value=MagicMock())
mocker.patch.object(generator, "_generate", return_value={"ok": True})
result = generator.single_loop_generate(
pipeline,
_build_workflow(),
"node",
_build_user(),
{"inputs": {"a": 1}},
streaming=False,
)
assert result == {"ok": True}
def test_handle_response_value_error_triggers_generate_task_stopped(generator, mocker):
pipeline = _build_pipeline()
workflow = _build_workflow()
app_entity = FakeRagPipelineGenerateEntity(task_id="t")
task_pipeline = MagicMock()
task_pipeline.process.side_effect = ValueError("I/O operation on closed file.")
mocker.patch.object(module, "WorkflowAppGenerateTaskPipeline", return_value=task_pipeline)
with pytest.raises(GenerateTaskStoppedError):
generator._handle_response(
application_generate_entity=app_entity,
workflow=workflow,
queue_manager=MagicMock(),
user=_build_user(),
draft_var_saver_factory=MagicMock(),
stream=False,
)
def test_build_document_sets_metadata_for_builtin_fields(generator, mocker):
class DummyDocument(SimpleNamespace):
pass
mocker.patch.object(module, "Document", side_effect=lambda **kwargs: DummyDocument(**kwargs))
document = generator._build_document(
tenant_id="tenant",
dataset_id="ds",
built_in_field_enabled=True,
datasource_type=DatasourceProviderType.LOCAL_FILE,
datasource_info={"name": "file"},
created_from="rag-pipeline",
position=1,
account=_build_user(),
batch="batch",
document_form="text",
)
assert document.name == "file"
assert document.doc_metadata
def test_build_document_invalid_datasource_type(generator):
with pytest.raises(ValueError):
generator._build_document(
tenant_id="tenant",
dataset_id="ds",
built_in_field_enabled=False,
datasource_type="invalid",
datasource_info={},
created_from="rag-pipeline",
position=1,
account=_build_user(),
batch="batch",
document_form="text",
)
def test_format_datasource_info_list_non_online_drive(generator):
result = generator._format_datasource_info_list(
DatasourceProviderType.LOCAL_FILE,
[{"name": "file"}],
_build_pipeline(),
_build_workflow(),
"start",
_build_user(),
)
assert result == [{"name": "file"}]
def test_format_datasource_info_list_missing_node_data(generator):
workflow = MagicMock(graph_dict={"nodes": []})
with pytest.raises(ValueError):
generator._format_datasource_info_list(
DatasourceProviderType.ONLINE_DRIVE,
[],
_build_pipeline(),
workflow,
"start",
_build_user(),
)
def test_format_datasource_info_list_online_drive_folder(generator, mocker):
workflow = MagicMock(
graph_dict={
"nodes": [
{
"id": "start",
"data": {
"plugin_id": "p",
"provider_name": "provider",
"datasource_name": "drive",
"credential_id": "cred",
},
}
]
}
)
runtime = MagicMock()
runtime.runtime = SimpleNamespace(credentials=None)
runtime.datasource_provider_type.return_value = DatasourceProviderType.ONLINE_DRIVE
mocker.patch(
"core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime",
return_value=runtime,
)
mocker.patch.object(module.DatasourceProviderService, "get_datasource_credentials", return_value={"k": "v"})
mocker.patch.object(
generator,
"_get_files_in_folder",
side_effect=lambda *args, **kwargs: args[4].append({"id": "f"}),
)
result = generator._format_datasource_info_list(
DatasourceProviderType.ONLINE_DRIVE,
[{"id": "folder", "type": "folder", "name": "Folder", "bucket": "b"}],
_build_pipeline(),
workflow,
"start",
_build_user(),
)
assert result == [{"id": "f"}]
def test_get_files_in_folder_recurses_and_collects(generator):
class File:
def __init__(self, id, name, type):
self.id = id
self.name = name
self.type = type
class FilesPage:
def __init__(self, files, is_truncated=False, next_page_parameters=None):
self.files = files
self.is_truncated = is_truncated
self.next_page_parameters = next_page_parameters
class Result:
def __init__(self, result):
self.result = result
class Runtime:
def __init__(self):
self.calls = []
def datasource_provider_type(self):
return DatasourceProviderType.ONLINE_DRIVE
def online_drive_browse_files(self, user_id, request, provider_type):
self.calls.append(request.next_page_parameters)
if request.prefix == "fd":
return iter([Result([FilesPage([File("f2", "file2", "file")], False, None)])])
if request.next_page_parameters is None:
return iter(
[
Result(
[FilesPage([File("f1", "file", "file"), File("fd", "folder", "folder")], True, {"page": 2})]
)
]
)
return iter([Result([FilesPage([File("f2", "file2", "file")], False, None)])])
runtime = Runtime()
all_files = []
generator._get_files_in_folder(
datasource_runtime=runtime,
prefix="root",
bucket="b",
user_id="user",
all_files=all_files,
datasource_info={},
)
assert {f["id"] for f in all_files} == {"f1", "f2"}

View File

@@ -0,0 +1,57 @@
import pytest
import core.app.apps.pipeline.pipeline_queue_manager as module
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.pipeline.pipeline_queue_manager import PipelineQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import (
QueueErrorEvent,
QueueMessageEndEvent,
QueueStopEvent,
QueueWorkflowFailedEvent,
QueueWorkflowPartialSuccessEvent,
QueueWorkflowSucceededEvent,
)
from dify_graph.model_runtime.entities.llm_entities import LLMResult
def test_publish_sets_stop_listen_and_raises_on_stopped(mocker):
manager = PipelineQueueManager(task_id="t", user_id="u", invoke_from=InvokeFrom.WEB_APP, app_mode="rag")
manager._q = mocker.MagicMock()
manager.stop_listen = mocker.MagicMock()
manager._is_stopped = mocker.MagicMock(return_value=True)
with pytest.raises(GenerateTaskStoppedError):
manager._publish(QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL), PublishFrom.APPLICATION_MANAGER)
manager.stop_listen.assert_called_once()
def test_publish_stop_events_trigger_stop_listen(mocker):
manager = PipelineQueueManager(task_id="t", user_id="u", invoke_from=InvokeFrom.WEB_APP, app_mode="rag")
manager._q = mocker.MagicMock()
manager.stop_listen = mocker.MagicMock()
manager._is_stopped = mocker.MagicMock(return_value=False)
for event in [
QueueErrorEvent(error=ValueError("bad")),
QueueMessageEndEvent(llm_result=LLMResult.model_construct()),
QueueWorkflowSucceededEvent(),
QueueWorkflowFailedEvent(error="failed", exceptions_count=1),
QueueWorkflowPartialSuccessEvent(exceptions_count=1),
]:
manager.stop_listen.reset_mock()
manager._publish(event, PublishFrom.TASK_PIPELINE)
manager.stop_listen.assert_called_once()
def test_publish_non_stop_event_no_stop_listen(mocker):
manager = PipelineQueueManager(task_id="t", user_id="u", invoke_from=InvokeFrom.WEB_APP, app_mode="rag")
manager._q = mocker.MagicMock()
manager.stop_listen = mocker.MagicMock()
manager._is_stopped = mocker.MagicMock(return_value=False)
non_stop_event = mocker.MagicMock(spec=module.AppQueueEvent)
manager._publish(non_stop_event, PublishFrom.TASK_PIPELINE)
manager.stop_listen.assert_not_called()

View File

@@ -0,0 +1,297 @@
"""
Unit tests for PipelineRunner behavior.
Asserts correct event handling, error propagation, and user invocation logic.
Primary collaborators: PipelineRunner, InvokeFrom, GraphRunFailedEvent, UserFrom, and mocked dependencies.
Cross-references: core.app.apps.pipeline.pipeline_runner, core.app.entities.app_invoke_entities.
"""
"""Unit tests for PipelineRunner behavior.
This module validates core control-flow outcomes for
``core.app.apps.pipeline.pipeline_runner``: app/workflow lookup, graph
initialization guards, invoke-source to user-source resolution, and failed-run
event handling. Invariants asserted here include strict graph-config
validation, correct ``InvokeFrom`` to ``UserFrom`` mapping, and publishing
error paths driven by ``GraphRunFailedEvent`` through mocked collaborators.
Primary collaborators include ``PipelineRunner``,
``core.app.entities.app_invoke_entities.InvokeFrom``, ``GraphRunFailedEvent``,
``UserFrom``, and patched DB/runtime dependencies used by the runner.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import core.app.apps.pipeline.pipeline_runner as module
from core.app.apps.pipeline.pipeline_runner import PipelineRunner
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from dify_graph.graph_events import GraphRunFailedEvent
def _build_app_generate_entity() -> SimpleNamespace:
app_config = SimpleNamespace(app_id="pipe", workflow_id="wf", tenant_id="tenant")
return SimpleNamespace(
app_config=app_config,
invoke_from=InvokeFrom.WEB_APP,
user_id="user",
trace_manager=MagicMock(),
inputs={"input1": "v1"},
files=[],
workflow_execution_id="run",
document_id="doc",
original_document_id=None,
batch="batch",
dataset_id="ds",
datasource_type="local_file",
datasource_info={"name": "file"},
start_node_id="start",
call_depth=0,
single_iteration_run=None,
single_loop_run=None,
)
@pytest.fixture
def runner():
app_generate_entity = _build_app_generate_entity()
queue_manager = MagicMock()
variable_loader = MagicMock()
workflow = MagicMock()
workflow_execution_repository = MagicMock()
workflow_node_execution_repository = MagicMock()
return PipelineRunner(
application_generate_entity=app_generate_entity,
queue_manager=queue_manager,
variable_loader=variable_loader,
workflow=workflow,
system_user_id="sys",
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
)
def test_get_app_id(runner):
assert runner._get_app_id() == "pipe"
def test_get_workflow_returns_workflow(mocker, runner):
pipeline = MagicMock(tenant_id="tenant", id="pipe")
workflow = MagicMock(id="wf")
query = MagicMock()
query.where.return_value.first.return_value = workflow
mocker.patch.object(module.db, "session", MagicMock(query=MagicMock(return_value=query)))
result = runner.get_workflow(pipeline=pipeline, workflow_id="wf")
assert result == workflow
def test_init_rag_pipeline_graph_invalid_config(mocker, runner):
workflow = MagicMock(id="wf", tenant_id="tenant", graph_dict={})
with pytest.raises(ValueError):
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
workflow.graph_dict = {"nodes": "bad", "edges": []}
with pytest.raises(ValueError):
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
workflow.graph_dict = {"nodes": [], "edges": "bad"}
with pytest.raises(ValueError):
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
def test_init_rag_pipeline_graph_not_found(mocker, runner):
workflow = MagicMock(id="wf", tenant_id="tenant", graph_dict={"nodes": [], "edges": []})
mocker.patch.object(module.Graph, "init", return_value=None)
with pytest.raises(ValueError):
runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock())
def test_update_document_status_on_failure(mocker, runner):
document = MagicMock()
query = MagicMock()
query.where.return_value.first.return_value = document
session = MagicMock()
session.query.return_value = query
mocker.patch.object(module.db, "session", session)
event = GraphRunFailedEvent(error="boom")
runner._update_document_status(event, document_id="doc", dataset_id="ds")
assert document.indexing_status == "error"
assert document.error == "boom"
session.commit.assert_called_once()
def test_run_pipeline_not_found(mocker):
app_generate_entity = _build_app_generate_entity()
app_generate_entity.invoke_from = InvokeFrom.WEB_APP
app_generate_entity.single_iteration_run = None
app_generate_entity.single_loop_run = None
query = MagicMock()
query.where.return_value.first.return_value = None
session = MagicMock()
session.query.return_value = query
mocker.patch.object(module.db, "session", session)
runner = PipelineRunner(
application_generate_entity=app_generate_entity,
queue_manager=MagicMock(),
variable_loader=MagicMock(),
workflow=MagicMock(),
system_user_id="sys",
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
)
with pytest.raises(ValueError):
runner.run()
def test_run_workflow_not_initialized(mocker):
app_generate_entity = _build_app_generate_entity()
pipeline = MagicMock(id="pipe")
query_pipeline = MagicMock()
query_pipeline.where.return_value.first.return_value = pipeline
session = MagicMock()
session.query.return_value = query_pipeline
mocker.patch.object(module.db, "session", session)
runner = PipelineRunner(
application_generate_entity=app_generate_entity,
queue_manager=MagicMock(),
variable_loader=MagicMock(),
workflow=MagicMock(),
system_user_id="sys",
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
)
runner.get_workflow = MagicMock(return_value=None)
with pytest.raises(ValueError):
runner.run()
def test_run_single_iteration_path(mocker):
app_generate_entity = _build_app_generate_entity()
app_generate_entity.single_iteration_run = MagicMock()
pipeline = MagicMock(id="pipe")
query_pipeline = MagicMock()
query_pipeline.where.return_value.first.return_value = pipeline
query_end_user = MagicMock()
query_end_user.where.return_value.first.return_value = MagicMock(session_id="sess")
session = MagicMock()
session.query.side_effect = [query_end_user, query_pipeline]
mocker.patch.object(module.db, "session", session)
runner = PipelineRunner(
application_generate_entity=app_generate_entity,
queue_manager=MagicMock(),
variable_loader=MagicMock(),
workflow=MagicMock(),
system_user_id="sys",
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
)
runner._resolve_user_from = MagicMock(return_value=UserFrom.ACCOUNT)
runner.get_workflow = MagicMock(
return_value=MagicMock(
id="wf",
tenant_id="tenant",
app_id="pipe",
graph_dict={},
type="rag-pipeline",
version="v1",
)
)
runner._prepare_single_node_execution = MagicMock(return_value=("graph", "pool", "state"))
runner._update_document_status = MagicMock()
runner._handle_event = MagicMock()
workflow_entry = MagicMock()
workflow_entry.graph_engine = MagicMock()
workflow_entry.run.return_value = [MagicMock()]
mocker.patch.object(module, "WorkflowEntry", return_value=workflow_entry)
mocker.patch.object(module, "WorkflowPersistenceLayer", return_value=MagicMock())
runner.run()
runner._prepare_single_node_execution.assert_called_once()
runner._handle_event.assert_called()
def test_run_normal_path_builds_graph(mocker):
app_generate_entity = _build_app_generate_entity()
pipeline = MagicMock(id="pipe")
query_pipeline = MagicMock()
query_pipeline.where.return_value.first.return_value = pipeline
query_end_user = MagicMock()
query_end_user.where.return_value.first.return_value = MagicMock(session_id="sess")
session = MagicMock()
session.query.side_effect = [query_end_user, query_pipeline]
mocker.patch.object(module.db, "session", session)
workflow = MagicMock(
id="wf",
tenant_id="tenant",
app_id="pipe",
graph_dict={"nodes": [], "edges": []},
environment_variables=[],
rag_pipeline_variables=[{"variable": "input1", "belong_to_node_id": "start"}],
type="rag-pipeline",
version="v1",
)
runner = PipelineRunner(
application_generate_entity=app_generate_entity,
queue_manager=MagicMock(),
variable_loader=MagicMock(),
workflow=workflow,
system_user_id="sys",
workflow_execution_repository=MagicMock(),
workflow_node_execution_repository=MagicMock(),
)
runner._resolve_user_from = MagicMock(return_value=UserFrom.ACCOUNT)
runner.get_workflow = MagicMock(return_value=workflow)
runner._init_rag_pipeline_graph = MagicMock(return_value="graph")
runner._update_document_status = MagicMock()
runner._handle_event = MagicMock()
mocker.patch.object(
module.RAGPipelineVariable,
"model_validate",
return_value=SimpleNamespace(belong_to_node_id="start", variable="input1"),
)
mocker.patch.object(module, "RAGPipelineVariableInput", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
mocker.patch.object(module, "VariablePool", side_effect=lambda **kwargs: SimpleNamespace(**kwargs))
workflow_entry = MagicMock()
workflow_entry.graph_engine = MagicMock()
workflow_entry.run.return_value = []
mocker.patch.object(module, "WorkflowEntry", return_value=workflow_entry)
mocker.patch.object(module, "WorkflowPersistenceLayer", return_value=MagicMock())
runner.run()
runner._init_rag_pipeline_graph.assert_called_once()

View File

@@ -1,3 +1,5 @@
from unittest.mock import MagicMock
import pytest
from core.app.apps.base_app_generator import BaseAppGenerator
@@ -366,3 +368,132 @@ def test_validate_inputs_optional_file_with_empty_string_ignores_default():
)
assert result is None
class TestBaseAppGeneratorExtras:
def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch):
base_app_generator = BaseAppGenerator()
variables = [
VariableEntity(
variable="file",
label="file",
type=VariableEntityType.FILE,
required=False,
allowed_file_types=[],
allowed_file_extensions=[],
allowed_file_upload_methods=[],
),
VariableEntity(
variable="file_list",
label="file_list",
type=VariableEntityType.FILE_LIST,
required=False,
allowed_file_types=[],
allowed_file_extensions=[],
allowed_file_upload_methods=[],
),
VariableEntity(
variable="json",
label="json",
type=VariableEntityType.JSON_OBJECT,
required=False,
),
]
monkeypatch.setattr(
"core.app.apps.base_app_generator.file_factory.build_from_mapping",
lambda mapping, tenant_id, config, strict_type_validation=False: "file-object",
)
monkeypatch.setattr(
"core.app.apps.base_app_generator.file_factory.build_from_mappings",
lambda mappings, tenant_id, config: ["file-1", "file-2"],
)
user_inputs = {
"file": {"id": "file-id"},
"file_list": [{"id": "file-1"}, {"id": "file-2"}],
"json": {"key": "value"},
}
prepared = base_app_generator._prepare_user_inputs(
user_inputs=user_inputs,
variables=variables,
tenant_id="tenant-id",
)
assert prepared["file"] == "file-object"
assert prepared["file_list"] == ["file-1", "file-2"]
assert prepared["json"] == {"key": "value"}
def test_prepare_user_inputs_rejects_invalid_dict_inputs(self):
base_app_generator = BaseAppGenerator()
variables = [
VariableEntity(
variable="text",
label="text",
type=VariableEntityType.TEXT_INPUT,
required=False,
)
]
with pytest.raises(ValueError, match="must be a string"):
base_app_generator._prepare_user_inputs(
user_inputs={"text": {"unexpected": "dict"}},
variables=variables,
tenant_id="tenant-id",
)
def test_prepare_user_inputs_rejects_invalid_list_inputs(self):
base_app_generator = BaseAppGenerator()
variables = [
VariableEntity(
variable="text",
label="text",
type=VariableEntityType.TEXT_INPUT,
required=False,
)
]
with pytest.raises(ValueError, match="must be a string"):
base_app_generator._prepare_user_inputs(
user_inputs={"text": [{"unexpected": "dict"}]},
variables=variables,
tenant_id="tenant-id",
)
def test_convert_to_event_stream(self):
base_app_generator = BaseAppGenerator()
assert base_app_generator.convert_to_event_stream({"ok": True}) == {"ok": True}
def _gen():
yield {"delta": "hi"}
yield "ping"
converted = list(base_app_generator.convert_to_event_stream(_gen()))
assert converted[0].startswith("data: ")
assert "\n\n" in converted[0]
assert converted[1] == "event: ping\n\n"
def test_get_draft_var_saver_factory_debugger(self):
from core.app.entities.app_invoke_entities import InvokeFrom
from dify_graph.enums import NodeType
from models import Account
base_app_generator = BaseAppGenerator()
account = Account(name="Tester", email="tester@example.com")
account.id = "account-id"
account.tenant_id = "tenant-id"
factory = base_app_generator._get_draft_var_saver_factory(InvokeFrom.DEBUGGER, account)
saver = factory(
session=MagicMock(),
app_id="app-id",
node_id="node-id",
node_type=NodeType.START,
node_execution_id="node-exec-id",
)
assert saver is not None

View File

@@ -0,0 +1,61 @@
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueErrorEvent
class DummyQueueManager(AppQueueManager):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.published = []
def _publish(self, event, pub_from):
self.published.append((event, pub_from))
class TestBaseAppQueueManager:
def test_init_requires_user_id(self):
with pytest.raises(ValueError):
DummyQueueManager(task_id="t1", user_id="", invoke_from=InvokeFrom.SERVICE_API)
def test_publish_error_records_event(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
mock_redis.setex.return_value = True
manager = DummyQueueManager(task_id="t1", user_id="u1", invoke_from=InvokeFrom.SERVICE_API)
manager.publish_error(ValueError("boom"), PublishFrom.TASK_PIPELINE)
assert isinstance(manager.published[0][0], QueueErrorEvent)
def test_set_stop_flag_checks_user(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
mock_redis.get.return_value = b"end-user-u1"
AppQueueManager.set_stop_flag(task_id="t1", invoke_from=InvokeFrom.SERVICE_API, user_id="u1")
mock_redis.setex.assert_called_once()
def test_set_stop_flag_no_user_check(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
AppQueueManager.set_stop_flag_no_user_check(task_id="t1")
mock_redis.setex.assert_called_once()
def test_is_stopped_reads_cache(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
mock_redis.setex.return_value = True
mock_redis.get.return_value = b"1"
manager = DummyQueueManager(task_id="t1", user_id="u1", invoke_from=InvokeFrom.SERVICE_API)
assert manager._is_stopped() is True
def test_check_for_sqlalchemy_models_raises(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
mock_redis.setex.return_value = True
manager = DummyQueueManager(task_id="t1", user_id="u1", invoke_from=InvokeFrom.SERVICE_API)
bad = SimpleNamespace(_sa_instance_state=True)
with pytest.raises(TypeError):
manager._check_for_sqlalchemy_models(bad)

View File

@@ -0,0 +1,442 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.app.app_config.entities import (
AdvancedChatMessageEntity,
AdvancedChatPromptTemplateEntity,
AdvancedCompletionPromptTemplateEntity,
PromptTemplateEntity,
)
from core.app.apps.base_app_runner import AppRunner
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueAgentMessageEvent, QueueLLMChunkEvent, QueueMessageEndEvent
from dify_graph.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from dify_graph.model_runtime.entities.message_entities import (
AssistantPromptMessage,
ImagePromptMessageContent,
PromptMessageRole,
TextPromptMessageContent,
)
from dify_graph.model_runtime.entities.model_entities import ModelPropertyKey
from dify_graph.model_runtime.errors.invoke import InvokeBadRequestError
from models.model import AppMode
class _DummyParameterRule:
def __init__(self, name: str, use_template: str | None = None) -> None:
self.name = name
self.use_template = use_template
class _QueueRecorder:
def __init__(self) -> None:
self.events: list[object] = []
def publish(self, event, pub_from):
_ = pub_from
self.events.append(event)
class TestAppRunner:
def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch):
runner = AppRunner()
model_schema = SimpleNamespace(
model_properties={ModelPropertyKey.CONTEXT_SIZE: 100},
parameter_rules=[_DummyParameterRule("max_tokens")],
)
model_config = SimpleNamespace(
provider_model_bundle=object(),
model="mock",
model_schema=model_schema,
parameters={"max_tokens": 30},
)
monkeypatch.setattr(
"core.app.apps.base_app_runner.ModelInstance",
lambda provider_model_bundle, model: SimpleNamespace(get_llm_num_tokens=lambda messages: 80),
)
runner.recalc_llm_max_tokens(model_config, prompt_messages=[AssistantPromptMessage(content="hi")])
assert model_config.parameters["max_tokens"] == 20
def test_recalc_llm_max_tokens_returns_minus_one_when_no_context(self, monkeypatch):
runner = AppRunner()
model_schema = SimpleNamespace(
model_properties={},
parameter_rules=[_DummyParameterRule("max_tokens")],
)
model_config = SimpleNamespace(
provider_model_bundle=object(),
model="mock",
model_schema=model_schema,
parameters={"max_tokens": 30},
)
monkeypatch.setattr(
"core.app.apps.base_app_runner.ModelInstance",
lambda provider_model_bundle, model: SimpleNamespace(get_llm_num_tokens=lambda messages: 10),
)
assert runner.recalc_llm_max_tokens(model_config, prompt_messages=[]) == -1
def test_direct_output_streaming_publishes_chunks_and_end(self, monkeypatch):
runner = AppRunner()
queue = _QueueRecorder()
app_generate_entity = SimpleNamespace(model_conf=SimpleNamespace(model="mock"), stream=True)
monkeypatch.setattr("core.app.apps.base_app_runner.time.sleep", lambda _: None)
runner.direct_output(
queue_manager=queue,
app_generate_entity=app_generate_entity,
prompt_messages=[],
text="hi",
stream=True,
)
assert any(isinstance(event, QueueLLMChunkEvent) for event in queue.events)
assert isinstance(queue.events[-1], QueueMessageEndEvent)
def test_handle_invoke_result_direct_publishes_end_event(self):
runner = AppRunner()
queue = _QueueRecorder()
llm_result = LLMResult(
model="mock",
prompt_messages=[],
message=AssistantPromptMessage(content="done"),
usage=LLMUsage.empty_usage(),
)
runner._handle_invoke_result(
invoke_result=llm_result,
queue_manager=queue,
stream=False,
)
assert isinstance(queue.events[-1], QueueMessageEndEvent)
def test_handle_invoke_result_invalid_type_raises(self):
runner = AppRunner()
queue = _QueueRecorder()
with pytest.raises(NotImplementedError):
runner._handle_invoke_result(
invoke_result=["unexpected"],
queue_manager=queue,
stream=True,
)
def test_organize_prompt_messages_simple_template(self, monkeypatch):
runner = AppRunner()
model_config = SimpleNamespace(mode="chat", stop=["STOP"])
prompt_template_entity = PromptTemplateEntity(
prompt_type=PromptTemplateEntity.PromptType.SIMPLE,
simple_prompt_template="hello",
)
monkeypatch.setattr(
"core.app.apps.base_app_runner.SimplePromptTransform.get_prompt",
lambda self, **kwargs: (["simple-message"], ["simple-stop"]),
)
prompt_messages, stop = runner.organize_prompt_messages(
app_record=SimpleNamespace(mode=AppMode.CHAT.value),
model_config=model_config,
prompt_template_entity=prompt_template_entity,
inputs={},
files=[],
query="q",
)
assert prompt_messages == ["simple-message"]
assert stop == ["simple-stop"]
def test_organize_prompt_messages_advanced_completion_template(self, monkeypatch):
runner = AppRunner()
model_config = SimpleNamespace(mode="completion", stop=["<END>"])
captured: dict[str, object] = {}
prompt_template_entity = PromptTemplateEntity(
prompt_type=PromptTemplateEntity.PromptType.ADVANCED,
advanced_completion_prompt_template=AdvancedCompletionPromptTemplateEntity(
prompt="answer",
role_prefix=AdvancedCompletionPromptTemplateEntity.RolePrefixEntity(user="U", assistant="A"),
),
)
def _fake_advanced_prompt(self, **kwargs):
captured.update(kwargs)
return ["advanced-completion-message"]
monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt)
prompt_messages, stop = runner.organize_prompt_messages(
app_record=SimpleNamespace(mode=AppMode.CHAT.value),
model_config=model_config,
prompt_template_entity=prompt_template_entity,
inputs={},
files=[],
query="q",
)
assert prompt_messages == ["advanced-completion-message"]
assert stop == ["<END>"]
memory_config = captured["memory_config"]
assert memory_config.role_prefix.user == "U"
assert memory_config.role_prefix.assistant == "A"
def test_organize_prompt_messages_advanced_chat_template(self, monkeypatch):
runner = AppRunner()
model_config = SimpleNamespace(mode="chat", stop=["<END>"])
captured: dict[str, object] = {}
prompt_template_entity = PromptTemplateEntity(
prompt_type=PromptTemplateEntity.PromptType.ADVANCED,
advanced_chat_prompt_template=AdvancedChatPromptTemplateEntity(
messages=[
AdvancedChatMessageEntity(text="hello", role=PromptMessageRole.USER),
AdvancedChatMessageEntity(text="world", role=PromptMessageRole.ASSISTANT),
]
),
)
def _fake_advanced_prompt(self, **kwargs):
captured.update(kwargs)
return ["advanced-chat-message"]
monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt)
prompt_messages, stop = runner.organize_prompt_messages(
app_record=SimpleNamespace(mode=AppMode.CHAT.value),
model_config=model_config,
prompt_template_entity=prompt_template_entity,
inputs={},
files=[],
query="q",
)
assert prompt_messages == ["advanced-chat-message"]
assert stop == ["<END>"]
assert len(captured["prompt_template"]) == 2
def test_organize_prompt_messages_advanced_missing_templates_raise(self):
runner = AppRunner()
with pytest.raises(InvokeBadRequestError, match="Advanced completion prompt template is required"):
runner.organize_prompt_messages(
app_record=SimpleNamespace(mode=AppMode.CHAT.value),
model_config=SimpleNamespace(mode="completion", stop=[]),
prompt_template_entity=PromptTemplateEntity(prompt_type=PromptTemplateEntity.PromptType.ADVANCED),
inputs={},
files=[],
)
with pytest.raises(InvokeBadRequestError, match="Advanced chat prompt template is required"):
runner.organize_prompt_messages(
app_record=SimpleNamespace(mode=AppMode.CHAT.value),
model_config=SimpleNamespace(mode="chat", stop=[]),
prompt_template_entity=PromptTemplateEntity(prompt_type=PromptTemplateEntity.PromptType.ADVANCED),
inputs={},
files=[],
)
def test_handle_invoke_result_stream_routes_chunks_and_builds_message(self, monkeypatch):
runner = AppRunner()
queue = _QueueRecorder()
warning_logger = MagicMock()
monkeypatch.setattr("core.app.apps.base_app_runner._logger.warning", warning_logger)
image_content = ImagePromptMessageContent(
url="https://example.com/image.png", format="png", mime_type="image/png"
)
def _stream():
yield LLMResultChunk(
model="stream-model",
prompt_messages=[AssistantPromptMessage(content="prompt")],
delta=LLMResultChunkDelta(
index=0,
message=AssistantPromptMessage.model_construct(
content=[
"a",
TextPromptMessageContent(data="b"),
SimpleNamespace(data="c"),
image_content,
]
),
),
)
runner._handle_invoke_result(
invoke_result=_stream(),
queue_manager=queue,
stream=True,
agent=False,
)
assert isinstance(queue.events[0], QueueLLMChunkEvent)
assert isinstance(queue.events[-1], QueueMessageEndEvent)
assert queue.events[-1].llm_result.message.content == "abc"
warning_logger.assert_called_once()
def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors(self, monkeypatch):
runner = AppRunner()
queue = _QueueRecorder()
exception_logger = MagicMock()
monkeypatch.setattr("core.app.apps.base_app_runner._logger.exception", exception_logger)
monkeypatch.setattr(
runner,
"_handle_multimodal_image_content",
MagicMock(side_effect=RuntimeError("failed to save image")),
)
usage = LLMUsage.empty_usage()
def _stream():
yield LLMResultChunk(
model="agent-model",
prompt_messages=[AssistantPromptMessage(content="prompt")],
delta=LLMResultChunkDelta(
index=0,
message=AssistantPromptMessage(
content=[
ImagePromptMessageContent(
url="https://example.com/image.png",
format="png",
mime_type="image/png",
),
TextPromptMessageContent(data="done"),
]
),
usage=usage,
),
)
runner._handle_invoke_result_stream(
invoke_result=_stream(),
queue_manager=queue,
agent=True,
message_id="message-id",
user_id="user-id",
tenant_id="tenant-id",
)
assert isinstance(queue.events[0], QueueAgentMessageEvent)
assert isinstance(queue.events[-1], QueueMessageEndEvent)
assert queue.events[-1].llm_result.usage == usage
exception_logger.assert_called_once()
def test_handle_multimodal_image_content_fallback_return_branch(self, monkeypatch):
runner = AppRunner()
class _ToggleBool:
def __init__(self, values: list[bool]):
self._values = values
self._index = 0
def __bool__(self):
value = self._values[min(self._index, len(self._values) - 1)]
self._index += 1
return value
content = SimpleNamespace(
url=_ToggleBool([False, False]),
base64_data=_ToggleBool([True, False]),
mime_type="image/png",
)
db_session = SimpleNamespace(add=MagicMock(), commit=MagicMock(), refresh=MagicMock())
monkeypatch.setattr("core.app.apps.base_app_runner.ToolFileManager", lambda: MagicMock())
monkeypatch.setattr("core.app.apps.base_app_runner.db", SimpleNamespace(session=db_session))
queue_manager = SimpleNamespace(invoke_from=InvokeFrom.SERVICE_API, publish=MagicMock())
runner._handle_multimodal_image_content(
content=content,
message_id="message-id",
user_id="user-id",
tenant_id="tenant-id",
queue_manager=queue_manager,
)
db_session.add.assert_not_called()
queue_manager.publish.assert_not_called()
def test_check_hosting_moderation_direct_output_called(self, monkeypatch):
runner = AppRunner()
queue = _QueueRecorder()
app_generate_entity = SimpleNamespace(stream=False)
monkeypatch.setattr(
"core.app.apps.base_app_runner.HostingModerationFeature.check",
lambda self, application_generate_entity, prompt_messages: True,
)
direct_output = MagicMock()
monkeypatch.setattr(runner, "direct_output", direct_output)
result = runner.check_hosting_moderation(
application_generate_entity=app_generate_entity,
queue_manager=queue,
prompt_messages=[],
)
assert result is True
assert direct_output.called
def test_fill_in_inputs_from_external_data_tools(self, monkeypatch):
runner = AppRunner()
monkeypatch.setattr(
"core.app.apps.base_app_runner.ExternalDataFetch.fetch",
lambda self, tenant_id, app_id, external_data_tools, inputs, query: {"foo": "bar"},
)
result = runner.fill_in_inputs_from_external_data_tools(
tenant_id="tenant",
app_id="app",
external_data_tools=[],
inputs={},
query="q",
)
assert result == {"foo": "bar"}
def test_moderation_for_inputs_returns_result(self, monkeypatch):
runner = AppRunner()
monkeypatch.setattr(
"core.app.apps.base_app_runner.InputModeration.check",
lambda self, app_id, tenant_id, app_config, inputs, query, message_id, trace_manager: (True, {}, ""),
)
app_generate_entity = SimpleNamespace(app_config=SimpleNamespace(), trace_manager=None)
result = runner.moderation_for_inputs(
app_id="app",
tenant_id="tenant",
app_generate_entity=app_generate_entity,
inputs={},
query="q",
message_id="msg",
)
assert result == (True, {}, "")
def test_query_app_annotations_to_reply(self, monkeypatch):
runner = AppRunner()
monkeypatch.setattr(
"core.app.apps.base_app_runner.AnnotationReplyFeature.query",
lambda self, app_record, message, query, user_id, invoke_from: "reply",
)
response = runner.query_app_annotations_to_reply(
app_record=SimpleNamespace(),
message=SimpleNamespace(),
query="hello",
user_id="user",
invoke_from=InvokeFrom.WEB_APP,
)
assert response == "reply"

View File

@@ -0,0 +1,7 @@
from core.app.apps.exc import GenerateTaskStoppedError
class TestAppsExceptions:
def test_generate_task_stopped_error(self):
err = GenerateTaskStoppedError("stopped")
assert str(err) == "stopped"

View File

@@ -13,9 +13,11 @@ from core.app.app_config.entities import (
PromptTemplateEntity,
)
from core.app.apps import message_based_app_generator
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, InvokeFrom
from models.model import AppMode, Conversation, Message
from services.errors.app_model_config import AppModelConfigBrokenError
class DummyModelConf:
@@ -125,3 +127,55 @@ def test_init_generate_records_sets_conversation_fields_for_chat_entity():
assert entity.conversation_id == "generated-conversation-id"
assert entity.is_new_conversation is True
assert conversation.id == "generated-conversation-id"
class TestMessageBasedAppGeneratorExtras:
def test_handle_response_closed_file_raises_stopped(self, monkeypatch):
generator = MessageBasedAppGenerator()
class _Pipeline:
def __init__(self, **kwargs) -> None:
_ = kwargs
def process(self):
raise ValueError("I/O operation on closed file.")
monkeypatch.setattr(
"core.app.apps.message_based_app_generator.EasyUIBasedGenerateTaskPipeline",
_Pipeline,
)
with pytest.raises(GenerateTaskStoppedError):
generator._handle_response(
application_generate_entity=_make_chat_generate_entity(_make_app_config(AppMode.CHAT)),
queue_manager=SimpleNamespace(),
conversation=SimpleNamespace(id="conv"),
message=SimpleNamespace(id="msg"),
user=SimpleNamespace(),
stream=False,
)
def test_get_app_model_config_requires_valid_config(self, monkeypatch):
generator = MessageBasedAppGenerator()
app_model = SimpleNamespace(id="app", app_model_config_id=None, app_model_config=None)
with pytest.raises(AppModelConfigBrokenError):
generator._get_app_model_config(app_model, conversation=None)
conversation = SimpleNamespace(app_model_config_id="missing-id")
monkeypatch.setattr(
message_based_app_generator, "db", SimpleNamespace(session=SimpleNamespace(scalar=lambda _: None))
)
with pytest.raises(AppModelConfigBrokenError):
generator._get_app_model_config(app_model=SimpleNamespace(id="app"), conversation=conversation)
def test_get_conversation_introduction_handles_missing_inputs(self):
app_config = _make_app_config(AppMode.CHAT)
app_config.additional_features.opening_statement = "Hello {{name}}"
entity = _make_chat_generate_entity(app_config)
entity.inputs = {}
generator = MessageBasedAppGenerator()
assert generator._get_conversation_introduction(entity) == "Hello {name}"

View File

@@ -0,0 +1,65 @@
from unittest.mock import Mock, patch
import pytest
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueErrorEvent, QueueMessageEndEvent, QueueStopEvent
class TestMessageBasedAppQueueManager:
def test_publish_stops_on_terminal_events(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
mock_redis.setex.return_value = True
manager = MessageBasedAppQueueManager(
task_id="t1",
user_id="u1",
invoke_from=InvokeFrom.SERVICE_API,
conversation_id="c1",
app_mode="chat",
message_id="m1",
)
manager.stop_listen = Mock()
manager._is_stopped = Mock(return_value=False)
manager._publish(QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL), Mock())
manager.stop_listen.assert_called_once()
def test_publish_raises_when_stopped(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
mock_redis.setex.return_value = True
manager = MessageBasedAppQueueManager(
task_id="t1",
user_id="u1",
invoke_from=InvokeFrom.SERVICE_API,
conversation_id="c1",
app_mode="chat",
message_id="m1",
)
manager._is_stopped = Mock(return_value=True)
with pytest.raises(GenerateTaskStoppedError):
manager._publish(QueueErrorEvent(error=ValueError("boom")), PublishFrom.APPLICATION_MANAGER)
def test_publish_enqueues_message_end(self):
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
mock_redis.setex.return_value = True
manager = MessageBasedAppQueueManager(
task_id="t1",
user_id="u1",
invoke_from=InvokeFrom.SERVICE_API,
conversation_id="c1",
app_mode="chat",
message_id="m1",
)
manager._is_stopped = Mock(return_value=False)
manager.stop_listen = Mock()
manager._publish(QueueMessageEndEvent(), PublishFrom.TASK_PIPELINE)
assert manager._q.qsize() == 1

View File

@@ -0,0 +1,29 @@
from unittest.mock import Mock, patch
from core.app.apps.message_generator import MessageGenerator
from models.model import AppMode
class TestMessageGenerator:
def test_get_response_topic(self):
channel = Mock()
channel.topic.return_value = "topic"
with patch("core.app.apps.message_generator.get_pubsub_broadcast_channel", return_value=channel):
topic = MessageGenerator.get_response_topic(AppMode.WORKFLOW, "run-1")
assert topic == "topic"
expected_key = MessageGenerator._make_channel_key(AppMode.WORKFLOW, "run-1")
channel.topic.assert_called_once_with(expected_key)
def test_retrieve_events_passes_arguments(self):
with (
patch("core.app.apps.message_generator.MessageGenerator.get_response_topic", return_value="topic"),
patch(
"core.app.apps.message_generator.stream_topic_events", return_value=iter([{"event": "ping"}])
) as mock_stream,
):
events = list(MessageGenerator.retrieve_events(AppMode.WORKFLOW, "run-1", idle_timeout=1, ping_interval=2))
assert events == [{"event": "ping"}]
mock_stream.assert_called_once()

View File

@@ -6,6 +6,7 @@ import queue
import pytest
from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.streaming_utils import _normalize_terminal_events, stream_topic_events
from core.app.entities.task_entities import StreamEvent
from models.model import AppMode
@@ -78,3 +79,30 @@ def test_retrieve_events_calls_on_subscribe_after_subscription(monkeypatch):
assert event["event"] == StreamEvent.WORKFLOW_FINISHED.value
with pytest.raises(StopIteration):
next(generator)
def test_normalize_terminal_events_defaults():
assert _normalize_terminal_events(None) == {
StreamEvent.WORKFLOW_FINISHED.value,
StreamEvent.WORKFLOW_PAUSED.value,
}
def test_stream_topic_events_emits_ping_and_idle_timeout(monkeypatch):
topic = FakeTopic()
times = [1000.0, 1000.0, 1001.0, 1001.0, 1002.0]
def fake_time():
return times.pop(0)
monkeypatch.setattr("core.app.apps.streaming_utils.time.time", fake_time)
generator = stream_topic_events(
topic=topic,
idle_timeout=10.0,
ping_interval=1.0,
)
assert next(generator) == StreamEvent.PING.value
# next receive yields None -> ping interval triggers
assert next(generator) == StreamEvent.PING.value

View File

@@ -0,0 +1,261 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
import pytest
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.entities.queue_entities import (
QueueAgentLogEvent,
QueueIterationCompletedEvent,
QueueLoopCompletedEvent,
QueueTextChunkEvent,
QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
)
from dify_graph.entities.pause_reason import HumanInputRequired
from dify_graph.enums import NodeType
from dify_graph.graph_events import (
GraphRunPausedEvent,
GraphRunStartedEvent,
GraphRunSucceededEvent,
NodeRunAgentLogEvent,
NodeRunIterationSucceededEvent,
NodeRunLoopFailedEvent,
NodeRunStartedEvent,
NodeRunStreamChunkEvent,
)
from dify_graph.runtime import GraphRuntimeState, VariablePool
from dify_graph.system_variable import SystemVariable
class TestWorkflowBasedAppRunner:
def test_resolve_user_from(self):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
assert runner._resolve_user_from(InvokeFrom.EXPLORE) == UserFrom.ACCOUNT
assert runner._resolve_user_from(InvokeFrom.DEBUGGER) == UserFrom.ACCOUNT
assert runner._resolve_user_from(InvokeFrom.WEB_APP) == UserFrom.END_USER
def test_init_graph_validates_graph_structure(self):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable.default()),
start_at=0.0,
)
with pytest.raises(ValueError, match="nodes or edges not found"):
runner._init_graph(
graph_config={},
graph_runtime_state=runtime_state,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
with pytest.raises(ValueError, match="nodes in workflow graph must be a list"):
runner._init_graph(
graph_config={"nodes": {}, "edges": []},
graph_runtime_state=runtime_state,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
with pytest.raises(ValueError, match="edges in workflow graph must be a list"):
runner._init_graph(
graph_config={"nodes": [], "edges": {}},
graph_runtime_state=runtime_state,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
)
def test_prepare_single_node_execution_requires_run(self):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
workflow = SimpleNamespace(environment_variables=[], graph_dict={})
with pytest.raises(ValueError, match="Neither single_iteration_run nor single_loop_run"):
runner._prepare_single_node_execution(workflow, None, None)
def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable.default()),
start_at=0.0,
)
graph_config = {
"nodes": [{"id": "node-1", "data": {"type": "start", "version": "1"}}],
"edges": [],
}
workflow = SimpleNamespace(tenant_id="tenant", id="workflow", graph_dict=graph_config)
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.Graph.init",
lambda **kwargs: SimpleNamespace(),
)
class _NodeCls:
@staticmethod
def extract_variable_selector_to_variable_mapping(graph_config, config):
return {}
from core.app.apps import workflow_app_runner
monkeypatch.setitem(
workflow_app_runner.NODE_TYPE_CLASSES_MAPPING,
NodeType.START,
{"1": _NodeCls},
)
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.load_into_variable_pool",
lambda **kwargs: None,
)
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.WorkflowEntry.mapping_user_inputs_to_variable_pool",
lambda **kwargs: None,
)
graph, variable_pool = runner._get_graph_and_variable_pool_for_single_node_run(
workflow=workflow,
node_id="node-1",
user_inputs={},
graph_runtime_state=graph_runtime_state,
node_type_filter_key="iteration_id",
node_type_label="iteration",
)
assert graph is not None
assert variable_pool is graph_runtime_state.variable_pool
def test_handle_graph_run_events_and_pause_notifications(self, monkeypatch):
published: list[object] = []
class _QueueManager:
def publish(self, event, publish_from):
published.append((event, publish_from))
runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app")
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable.default()),
start_at=0.0,
)
graph_runtime_state.register_paused_node("node-1")
workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state))
emails: list[dict] = []
class _Dispatch:
def apply_async(self, *, kwargs, queue):
emails.append({"kwargs": kwargs, "queue": queue})
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.dispatch_human_input_email_task",
_Dispatch(),
)
reason = HumanInputRequired(
form_id="form",
form_content="content",
node_id="node-1",
node_title="Node",
)
runner._handle_event(workflow_entry, GraphRunStartedEvent())
runner._handle_event(workflow_entry, GraphRunSucceededEvent(outputs={"ok": True}))
runner._handle_event(workflow_entry, GraphRunPausedEvent(reasons=[reason], outputs={}))
assert any(isinstance(event, QueueWorkflowStartedEvent) for event, _ in published)
assert any(isinstance(event, QueueWorkflowSucceededEvent) for event, _ in published)
paused_event = next(event for event, _ in published if isinstance(event, QueueWorkflowPausedEvent))
assert paused_event.paused_nodes == ["node-1"]
assert emails
def test_handle_node_events_publishes_queue_events(self):
published: list[object] = []
class _QueueManager:
def publish(self, event, publish_from):
published.append(event)
runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app")
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable.default()),
start_at=0.0,
)
workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state))
runner._handle_event(
workflow_entry,
NodeRunStartedEvent(
id="exec",
node_id="node",
node_type=NodeType.START,
node_title="Start",
start_at=datetime.utcnow(),
),
)
runner._handle_event(
workflow_entry,
NodeRunStreamChunkEvent(
id="exec",
node_id="node",
node_type=NodeType.START,
selector=["node", "text"],
chunk="hi",
is_final=False,
),
)
runner._handle_event(
workflow_entry,
NodeRunAgentLogEvent(
id="exec",
node_id="node",
node_type=NodeType.START,
message_id="msg",
label="label",
node_execution_id="exec",
parent_id=None,
error=None,
status="done",
data={},
metadata={},
),
)
runner._handle_event(
workflow_entry,
NodeRunIterationSucceededEvent(
id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="Iter",
start_at=datetime.utcnow(),
inputs={},
outputs={"ok": True},
metadata={},
steps=1,
),
)
runner._handle_event(
workflow_entry,
NodeRunLoopFailedEvent(
id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="Loop",
start_at=datetime.utcnow(),
inputs={},
outputs={},
metadata={},
steps=1,
error="boom",
),
)
assert any(isinstance(event, QueueTextChunkEvent) for event in published)
assert any(isinstance(event, QueueAgentLogEvent) for event in published)
assert any(isinstance(event, QueueIterationCompletedEvent) for event in published)
assert any(isinstance(event, QueueLoopCompletedEvent) for event in published)

View File

@@ -0,0 +1,61 @@
from types import SimpleNamespace
from unittest.mock import patch
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from models.model import AppMode
class TestWorkflowAppConfigManager:
def test_get_app_config(self):
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value)
workflow = SimpleNamespace(id="wf-1", features_dict={})
with (
patch(
"core.app.apps.workflow.app_config_manager.SensitiveWordAvoidanceConfigManager.convert",
return_value=None,
),
patch(
"core.app.apps.workflow.app_config_manager.WorkflowVariablesConfigManager.convert",
return_value=[],
),
):
app_config = WorkflowAppConfigManager.get_app_config(app_model, workflow)
assert app_config.workflow_id == "wf-1"
assert app_config.app_mode == AppMode.WORKFLOW
def test_config_validate_filters_keys(self):
def _add_key(key, value):
def _inner(*args, **kwargs):
# Support both positional and keyword arguments for config
if "config" in kwargs:
config = kwargs["config"]
elif len(args) > 0:
config = args[0]
else:
config = {}
config[key] = value
return config, [key]
return _inner
with (
patch(
"core.app.apps.workflow.app_config_manager.FileUploadConfigManager.validate_and_set_defaults",
side_effect=_add_key("file_upload", 1),
),
patch(
"core.app.apps.workflow.app_config_manager.TextToSpeechConfigManager.validate_and_set_defaults",
side_effect=_add_key("text_to_speech", 2),
),
patch(
"core.app.apps.workflow.app_config_manager.SensitiveWordAvoidanceConfigManager.validate_and_set_defaults",
side_effect=_add_key("sensitive_word_avoidance", 3),
),
):
filtered = WorkflowAppConfigManager.config_validate(tenant_id="t1", config={})
assert filtered["file_upload"] == 1
assert filtered["text_to_speech"] == 2
assert filtered["sensitive_word_avoidance"] == 3

View File

@@ -0,0 +1,188 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.ops.ops_trace_manager import TraceQueueManager
from models.model import AppMode
class TestWorkflowAppGeneratorValidation:
def test_should_prepare_user_inputs(self):
generator = WorkflowAppGenerator()
assert generator._should_prepare_user_inputs({}) is True
assert generator._should_prepare_user_inputs({SKIP_PREPARE_USER_INPUTS_KEY: True}) is False
def test_single_iteration_generate_validates_args(self):
generator = WorkflowAppGenerator()
with pytest.raises(ValueError, match="node_id is required"):
generator.single_iteration_generate(
app_model=SimpleNamespace(),
workflow=SimpleNamespace(),
node_id="",
user=SimpleNamespace(),
args={"inputs": {}},
streaming=False,
)
with pytest.raises(ValueError, match="inputs is required"):
generator.single_iteration_generate(
app_model=SimpleNamespace(),
workflow=SimpleNamespace(),
node_id="node",
user=SimpleNamespace(),
args={},
streaming=False,
)
def test_single_loop_generate_validates_args(self):
generator = WorkflowAppGenerator()
with pytest.raises(ValueError, match="node_id is required"):
generator.single_loop_generate(
app_model=SimpleNamespace(),
workflow=SimpleNamespace(),
node_id="",
user=SimpleNamespace(),
args=SimpleNamespace(inputs={}),
streaming=False,
)
with pytest.raises(ValueError, match="inputs is required"):
generator.single_loop_generate(
app_model=SimpleNamespace(),
workflow=SimpleNamespace(),
node_id="node",
user=SimpleNamespace(),
args=SimpleNamespace(inputs=None),
streaming=False,
)
class TestWorkflowAppGeneratorHandleResponse:
def test_handle_response_closed_file_raises_stopped(self, monkeypatch):
generator = WorkflowAppGenerator()
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
task_id="task",
app_config=app_config,
inputs={},
files=[],
user_id="user",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
extras={},
trace_manager=None,
workflow_execution_id="run-id",
call_depth=0,
)
class _Pipeline:
def __init__(self, **kwargs) -> None:
_ = kwargs
def process(self):
raise ValueError("I/O operation on closed file.")
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowAppGenerateTaskPipeline",
_Pipeline,
)
with pytest.raises(GenerateTaskStoppedError):
generator._handle_response(
application_generate_entity=application_generate_entity,
workflow=SimpleNamespace(),
queue_manager=SimpleNamespace(),
user=SimpleNamespace(),
draft_var_saver_factory=lambda **kwargs: None,
stream=False,
)
class TestWorkflowAppGeneratorGenerate:
def test_generate_skips_prepare_inputs_when_flag_set(self, monkeypatch):
generator = WorkflowAppGenerator()
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config",
lambda app_model, workflow: app_config,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.FileUploadConfigManager.convert",
lambda features_dict, is_vision=False: None,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.file_factory.build_from_mappings",
lambda **kwargs: [],
)
DummyTraceQueueManager = type(
"_DummyTraceQueueManager",
(TraceQueueManager,),
{
"__init__": lambda self, app_id=None, user_id=None: (
setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id)
)
},
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.TraceQueueManager",
DummyTraceQueueManager,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository",
lambda **kwargs: SimpleNamespace(),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository",
lambda **kwargs: SimpleNamespace(),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.db",
SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.sessionmaker",
lambda **kwargs: SimpleNamespace(),
)
prepare_inputs = pytest.fail
monkeypatch.setattr(generator, "_prepare_user_inputs", lambda **kwargs: prepare_inputs())
monkeypatch.setattr(generator, "_generate", lambda **kwargs: {"ok": True})
result = generator.generate(
app_model=SimpleNamespace(id="app", tenant_id="tenant"),
workflow=SimpleNamespace(features_dict={}),
user=SimpleNamespace(id="user", session_id="session"),
args={"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True},
invoke_from=InvokeFrom.WEB_APP,
streaming=False,
call_depth=0,
)
assert result == {"ok": True}

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from core.app.apps.base_app_queue_manager import PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import QueueMessageEndEvent, QueuePingEvent
class TestWorkflowAppQueueManager:
def test_publish_stop_events_trigger_stop(self):
manager = WorkflowAppQueueManager(
task_id="task",
user_id="user",
invoke_from=InvokeFrom.DEBUGGER,
app_mode="workflow",
)
manager._is_stopped = lambda: True
with pytest.raises(GenerateTaskStoppedError):
manager._publish(QueueMessageEndEvent(llm_result=None), PublishFrom.APPLICATION_MANAGER)
def test_publish_non_stop_event_does_not_raise(self):
manager = WorkflowAppQueueManager(
task_id="task",
user_id="user",
invoke_from=InvokeFrom.DEBUGGER,
app_mode="workflow",
)
manager._publish(QueuePingEvent(), PublishFrom.TASK_PIPELINE)

View File

@@ -0,0 +1,9 @@
from core.app.apps.workflow.errors import WorkflowPausedInBlockingModeError
class TestWorkflowErrors:
def test_workflow_paused_in_blocking_mode_error_attributes(self):
err = WorkflowPausedInBlockingModeError()
assert err.error_code == "workflow_paused_in_blocking_mode"
assert err.code == 400
assert "blocking response mode" in err.description

View File

@@ -0,0 +1,133 @@
from collections.abc import Generator
from core.app.apps.workflow.generate_response_converter import WorkflowAppGenerateResponseConverter
from core.app.entities.task_entities import (
ErrorStreamResponse,
NodeFinishStreamResponse,
NodeStartStreamResponse,
PingStreamResponse,
WorkflowAppBlockingResponse,
WorkflowAppStreamResponse,
)
from dify_graph.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
class TestWorkflowGenerateResponseConverter:
def test_blocking_full_response(self):
blocking = WorkflowAppBlockingResponse(
task_id="t1",
workflow_run_id="r1",
data=WorkflowAppBlockingResponse.Data(
id="exec-1",
workflow_id="wf-1",
status=WorkflowExecutionStatus.SUCCEEDED,
outputs={"ok": True},
error=None,
elapsed_time=1.2,
total_tokens=10,
total_steps=2,
created_at=1,
finished_at=2,
),
)
response = WorkflowAppGenerateResponseConverter.convert_blocking_full_response(blocking)
assert response["workflow_run_id"] == "r1"
def test_stream_simple_response_node_events(self):
node_start = NodeStartStreamResponse(
task_id="t1",
workflow_run_id="r1",
data=NodeStartStreamResponse.Data(
id="e1",
node_id="n1",
node_type="answer",
title="Answer",
index=1,
created_at=1,
),
)
node_finish = NodeFinishStreamResponse(
task_id="t1",
workflow_run_id="r1",
data=NodeFinishStreamResponse.Data(
id="e1",
node_id="n1",
node_type="answer",
title="Answer",
index=1,
status=WorkflowNodeExecutionStatus.SUCCEEDED,
elapsed_time=0.1,
created_at=1,
finished_at=2,
),
)
def stream() -> Generator[WorkflowAppStreamResponse, None, None]:
yield WorkflowAppStreamResponse(workflow_run_id="r1", stream_response=PingStreamResponse(task_id="t1"))
yield WorkflowAppStreamResponse(workflow_run_id="r1", stream_response=node_start)
yield WorkflowAppStreamResponse(workflow_run_id="r1", stream_response=node_finish)
yield WorkflowAppStreamResponse(
workflow_run_id="r1", stream_response=ErrorStreamResponse(task_id="t1", err=ValueError("boom"))
)
converted = list(WorkflowAppGenerateResponseConverter.convert_stream_simple_response(stream()))
assert converted[0] == "ping"
assert converted[1]["event"] == "node_started"
assert converted[2]["event"] == "node_finished"
assert converted[3]["event"] == "error"
def test_convert_stream_simple_response_handles_ping_and_nodes(self):
def _gen():
yield WorkflowAppStreamResponse(stream_response=PingStreamResponse(task_id="task"))
yield WorkflowAppStreamResponse(
workflow_run_id="run",
stream_response=NodeStartStreamResponse(
task_id="task",
workflow_run_id="run",
data=NodeStartStreamResponse.Data(
id="node-exec",
node_id="node",
node_type="start",
title="Start",
index=1,
created_at=1,
),
),
)
yield WorkflowAppStreamResponse(
workflow_run_id="run",
stream_response=NodeFinishStreamResponse(
task_id="task",
workflow_run_id="run",
data=NodeFinishStreamResponse.Data(
id="node-exec",
node_id="node",
node_type="start",
title="Start",
index=1,
status=WorkflowNodeExecutionStatus.SUCCEEDED,
outputs={},
created_at=1,
finished_at=2,
elapsed_time=1.0,
error=None,
),
),
)
chunks = list(WorkflowAppGenerateResponseConverter.convert_stream_simple_response(_gen()))
assert chunks[0] == "ping"
assert chunks[1]["event"] == "node_started"
assert chunks[2]["event"] == "node_finished"
def test_convert_stream_full_response_handles_error(self):
def _gen():
yield WorkflowAppStreamResponse(
workflow_run_id="run",
stream_response=ErrorStreamResponse(task_id="task", err=ValueError("boom")),
)
chunks = list(WorkflowAppGenerateResponseConverter.convert_stream_full_response(_gen()))
assert chunks[0]["event"] == "error"

View File

@@ -0,0 +1,868 @@
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime
from types import SimpleNamespace
import pytest
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.queue_entities import (
QueueAgentLogEvent,
QueueErrorEvent,
QueueHumanInputFormFilledEvent,
QueueHumanInputFormTimeoutEvent,
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
QueueLoopCompletedEvent,
QueueLoopNextEvent,
QueueLoopStartEvent,
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
QueueWorkflowPartialSuccessEvent,
QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
)
from core.app.entities.task_entities import (
ErrorStreamResponse,
MessageAudioEndStreamResponse,
MessageAudioStreamResponse,
PingStreamResponse,
WorkflowFinishStreamResponse,
WorkflowPauseStreamResponse,
WorkflowStartStreamResponse,
)
from core.base.tts.app_generator_tts_publisher import AudioTrunk
from dify_graph.enums import NodeType, WorkflowExecutionStatus
from dify_graph.runtime import GraphRuntimeState, VariablePool
from dify_graph.system_variable import SystemVariable
from models.enums import CreatorUserRole
from models.model import AppMode, EndUser
def _make_pipeline():
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
task_id="task",
app_config=app_config,
inputs={},
files=[],
user_id="user",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
trace_manager=None,
workflow_execution_id="run-id",
extras={},
call_depth=0,
)
workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={})
user = SimpleNamespace(id="user", session_id="session")
pipeline = WorkflowAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None),
user=user,
stream=False,
draft_var_saver_factory=lambda **kwargs: None,
)
return pipeline
class TestWorkflowGenerateTaskPipeline:
def test_to_blocking_response_handles_pause(self):
pipeline = _make_pipeline()
def _gen():
yield WorkflowPauseStreamResponse(
task_id="task",
workflow_run_id="run",
data=WorkflowPauseStreamResponse.Data(
workflow_run_id="run",
status=WorkflowExecutionStatus.PAUSED,
outputs={},
created_at=1,
elapsed_time=0.1,
total_tokens=0,
total_steps=0,
),
)
response = pipeline._to_blocking_response(_gen())
assert response.data.status == WorkflowExecutionStatus.PAUSED
def test_to_blocking_response_handles_finish(self):
pipeline = _make_pipeline()
def _gen():
yield WorkflowFinishStreamResponse(
task_id="task",
workflow_run_id="run",
data=WorkflowFinishStreamResponse.Data(
id="run",
workflow_id="workflow-id",
status=WorkflowExecutionStatus.SUCCEEDED,
outputs={"ok": True},
error=None,
elapsed_time=1.0,
total_tokens=5,
total_steps=2,
created_at=1,
finished_at=2,
),
)
response = pipeline._to_blocking_response(_gen())
assert response.data.outputs == {"ok": True}
def test_listen_audio_msg_returns_audio_stream(self):
pipeline = _make_pipeline()
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data"))
response = pipeline._listen_audio_msg(publisher=publisher, task_id="task")
assert isinstance(response, MessageAudioStreamResponse)
def test_handle_ping_event(self):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.ping_stream_response = lambda: PingStreamResponse(task_id="task")
responses = list(pipeline._handle_ping_event(QueuePingEvent()))
assert isinstance(responses[0], PingStreamResponse)
def test_handle_error_event(self):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.handle_error = lambda **kwargs: ValueError("boom")
pipeline._base_task_pipeline.error_to_stream_response = lambda err: err
responses = list(pipeline._handle_error_event(QueueErrorEvent(error=ValueError("boom"))))
assert isinstance(responses[0], ValueError)
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
@contextmanager
def _fake_session():
yield SimpleNamespace()
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
assert pipeline._workflow_execution_id == "run-id"
assert responses == ["started"]
def test_handle_node_succeeded_event_saves_output(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
pipeline._save_output_for_event = lambda event, node_execution_id: None
pipeline._workflow_execution_id = "run-id"
event = QueueNodeSucceededEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.START,
start_at=datetime.utcnow(),
inputs={},
outputs={},
process_data={},
)
responses = list(pipeline._handle_node_succeeded_event(event))
assert responses == ["done"]
def test_handle_workflow_failed_event_yields_error(self):
pipeline = _make_pipeline()
pipeline._workflow_execution_id = "run-id"
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish"
pipeline._base_task_pipeline.handle_error = lambda **kwargs: ValueError("boom")
pipeline._base_task_pipeline.error_to_stream_response = lambda err: err
responses = list(
pipeline._handle_workflow_failed_and_stop_events(QueueWorkflowFailedEvent(error="fail", exceptions_count=1))
)
assert responses[0] == "finish"
def test_handle_text_chunk_event_publishes_tts(self):
pipeline = _make_pipeline()
published: list[object] = []
class _Publisher:
def publish(self, message):
published.append(message)
event = QueueTextChunkEvent(text="hi", from_variable_selector=["x"])
queue_message = SimpleNamespace(event=event)
responses = list(
pipeline._handle_text_chunk_event(event, tts_publisher=_Publisher(), queue_message=queue_message)
)
assert responses[0].data.text == "hi"
assert published == [queue_message]
def test_dispatch_event_handles_node_failed(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
event = QueueNodeFailedEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.START,
start_at=datetime.utcnow(),
inputs={},
outputs={},
process_data={},
error="err",
)
assert list(pipeline._dispatch_event(event)) == ["done"]
def test_handle_stop_event_yields_finish(self):
pipeline = _make_pipeline()
pipeline._workflow_execution_id = "run-id"
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish"
responses = list(
pipeline._handle_workflow_failed_and_stop_events(
QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL)
)
)
assert responses == ["finish"]
def test_save_workflow_app_log_created_from(self):
pipeline = _make_pipeline()
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
pipeline._user_id = "user"
added: list[object] = []
class _Session:
def add(self, item):
added.append(item)
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added
def test_iteration_loop_and_human_input_handlers(self):
pipeline = _make_pipeline()
pipeline._workflow_execution_id = "run-id"
pipeline._workflow_response_converter.workflow_iteration_start_to_stream_response = lambda **kwargs: "iter"
pipeline._workflow_response_converter.workflow_iteration_next_to_stream_response = lambda **kwargs: "next"
pipeline._workflow_response_converter.workflow_iteration_completed_to_stream_response = lambda **kwargs: "done"
pipeline._workflow_response_converter.workflow_loop_start_to_stream_response = lambda **kwargs: "loop"
pipeline._workflow_response_converter.workflow_loop_next_to_stream_response = lambda **kwargs: "loop_next"
pipeline._workflow_response_converter.workflow_loop_completed_to_stream_response = lambda **kwargs: "loop_done"
pipeline._workflow_response_converter.human_input_form_filled_to_stream_response = lambda **kwargs: "filled"
pipeline._workflow_response_converter.human_input_form_timeout_to_stream_response = lambda **kwargs: "timeout"
pipeline._workflow_response_converter.handle_agent_log = lambda **kwargs: "log"
iter_start = QueueIterationStartEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
iter_next = QueueIterationNextEvent(
index=1,
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
node_run_index=1,
)
iter_done = QueueIterationCompletedEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
loop_start = QueueLoopStartEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
loop_next = QueueLoopNextEvent(
index=1,
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
node_run_index=1,
)
loop_done = QueueLoopCompletedEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="LLM",
start_at=datetime.utcnow(),
node_run_index=1,
)
filled_event = QueueHumanInputFormFilledEvent(
node_execution_id="exec",
node_id="node",
node_type=NodeType.LLM,
node_title="title",
rendered_content="content",
action_id="action",
action_text="action",
)
timeout_event = QueueHumanInputFormTimeoutEvent(
node_id="node",
node_type=NodeType.LLM,
node_title="title",
expiration_time=datetime.utcnow(),
)
agent_event = QueueAgentLogEvent(
id="log",
label="label",
node_execution_id="exec",
parent_id=None,
error=None,
status="done",
data={},
metadata={},
node_id="node",
)
assert list(pipeline._handle_iteration_start_event(iter_start)) == ["iter"]
assert list(pipeline._handle_iteration_next_event(iter_next)) == ["next"]
assert list(pipeline._handle_iteration_completed_event(iter_done)) == ["done"]
assert list(pipeline._handle_loop_start_event(loop_start)) == ["loop"]
assert list(pipeline._handle_loop_next_event(loop_next)) == ["loop_next"]
assert list(pipeline._handle_loop_completed_event(loop_done)) == ["loop_done"]
assert list(pipeline._handle_human_input_form_filled_event(filled_event)) == ["filled"]
assert list(pipeline._handle_human_input_form_timeout_event(timeout_event)) == ["timeout"]
assert list(pipeline._handle_agent_log_event(agent_event)) == ["log"]
def test_wrapper_process_stream_response_emits_audio_end(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._workflow_features_dict = {
"text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"}
}
pipeline._process_stream_response = lambda **kwargs: iter([PingStreamResponse(task_id="task")])
class _Publisher:
def __init__(self, *args, **kwargs):
self.calls = 0
def check_and_get_audio(self):
self.calls += 1
if self.calls == 1:
return AudioTrunk(status="stream", audio="data")
if self.calls == 2:
return None
return AudioTrunk(status="finish", audio="")
def publish(self, message):
return None
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher",
_Publisher,
)
responses = list(pipeline._wrapper_process_stream_response())
assert any(isinstance(item, MessageAudioStreamResponse) for item in responses)
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
def test_init_with_end_user_sets_role_and_system_user(self):
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
app_id="app",
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id="workflow-id",
)
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
task_id="task",
app_config=app_config,
inputs={},
files=[],
user_id="end-user-id",
stream=False,
invoke_from=InvokeFrom.WEB_APP,
trace_manager=None,
workflow_execution_id="run-id",
extras={},
call_depth=0,
)
workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={})
queue_manager = SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None)
end_user = EndUser(tenant_id="tenant", type="session", name="user", session_id="session-id")
end_user.id = "end-user-id"
pipeline = WorkflowAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
workflow=workflow,
queue_manager=queue_manager,
user=end_user,
stream=False,
draft_var_saver_factory=lambda **kwargs: None,
)
assert pipeline._created_by_role == CreatorUserRole.END_USER
assert pipeline._workflow_system_variables.user_id == "session-id"
def test_process_returns_stream_and_blocking_variants(self):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.stream = True
pipeline._wrapper_process_stream_response = lambda **kwargs: iter([PingStreamResponse(task_id="task")])
stream_response = list(pipeline.process())
assert len(stream_response) == 1
assert stream_response[0].workflow_run_id is None
pipeline._base_task_pipeline.stream = False
pipeline._wrapper_process_stream_response = lambda **kwargs: iter(
[
WorkflowFinishStreamResponse(
task_id="task",
workflow_run_id="run-id",
data=WorkflowFinishStreamResponse.Data(
id="run-id",
workflow_id="workflow-id",
status=WorkflowExecutionStatus.SUCCEEDED,
outputs={},
error=None,
elapsed_time=0.1,
total_tokens=0,
total_steps=0,
created_at=1,
finished_at=2,
),
)
]
)
blocking_response = pipeline.process()
assert blocking_response.workflow_run_id == "run-id"
def test_to_blocking_response_handles_error_and_unexpected_end(self):
pipeline = _make_pipeline()
def _error_gen():
yield ErrorStreamResponse(task_id="task", err=ValueError("boom"))
with pytest.raises(ValueError, match="boom"):
pipeline._to_blocking_response(_error_gen())
def _unexpected_gen():
yield PingStreamResponse(task_id="task")
with pytest.raises(ValueError, match="queue listening stopped unexpectedly"):
pipeline._to_blocking_response(_unexpected_gen())
def test_to_stream_response_tracks_workflow_run_id(self):
pipeline = _make_pipeline()
def _gen():
yield WorkflowStartStreamResponse(
task_id="task",
workflow_run_id="run-id",
data=WorkflowStartStreamResponse.Data(
id="run-id",
workflow_id="workflow-id",
inputs={},
created_at=1,
),
)
yield PingStreamResponse(task_id="task")
stream_responses = list(pipeline._to_stream_response(_gen()))
assert stream_responses[0].workflow_run_id == "run-id"
assert stream_responses[1].workflow_run_id == "run-id"
def test_listen_audio_msg_returns_none_without_publisher(self):
pipeline = _make_pipeline()
assert pipeline._listen_audio_msg(publisher=None, task_id="task") is None
def test_wrapper_process_stream_response_without_tts(self):
pipeline = _make_pipeline()
pipeline._workflow_features_dict = {}
pipeline._process_stream_response = lambda **kwargs: iter([PingStreamResponse(task_id="task")])
responses = list(pipeline._wrapper_process_stream_response())
assert responses == [PingStreamResponse(task_id="task")]
def test_wrapper_process_stream_response_final_audio_none_then_finish(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._workflow_features_dict = {
"text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"}
}
pipeline._process_stream_response = lambda **kwargs: iter([])
sleep_spy = []
class _Publisher:
def __init__(self, *args, **kwargs):
self.calls = 0
def check_and_get_audio(self):
self.calls += 1
if self.calls == 1:
return None
return AudioTrunk(status="finish", audio="")
def publish(self, message):
_ = message
time_values = iter([0.0, 0.0, 0.2])
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.time.time", lambda: next(time_values))
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.time.sleep", lambda _: sleep_spy.append(True)
)
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher",
_Publisher,
)
responses = list(pipeline._wrapper_process_stream_response())
assert sleep_spy
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
def test_wrapper_process_stream_response_handles_audio_exception(self, monkeypatch):
pipeline = _make_pipeline()
pipeline._workflow_features_dict = {
"text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"}
}
pipeline._process_stream_response = lambda **kwargs: iter([])
class _Publisher:
def __init__(self, *args, **kwargs):
self.called = False
def check_and_get_audio(self):
if not self.called:
self.called = True
raise RuntimeError("tts failure")
return AudioTrunk(status="finish", audio="")
def publish(self, message):
_ = message
logger_exception = []
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.time.time", lambda: 0.0)
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.logger.exception",
lambda *args, **kwargs: logger_exception.append((args, kwargs)),
)
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher",
_Publisher,
)
responses = list(pipeline._wrapper_process_stream_response())
assert logger_exception
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
def test_database_session_rolls_back_on_error(self, monkeypatch):
pipeline = _make_pipeline()
calls = {"commit": 0, "rollback": 0}
class _Session:
def __init__(self, *args, **kwargs):
_ = args, kwargs
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def commit(self):
calls["commit"] += 1
def rollback(self):
calls["rollback"] += 1
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.Session", _Session)
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
with pytest.raises(RuntimeError, match="db error"):
with pipeline._database_session():
raise RuntimeError("db error")
assert calls["commit"] == 0
assert calls["rollback"] == 1
def test_node_retry_and_started_handlers_cover_none_and_value(self):
pipeline = _make_pipeline()
pipeline._workflow_execution_id = "run-id"
retry_event = QueueNodeRetryEvent(
node_execution_id="exec",
node_id="node",
node_title="title",
node_type=NodeType.LLM,
node_run_index=1,
start_at=datetime.utcnow(),
provider_type="provider",
provider_id="provider-id",
error="error",
retry_index=1,
)
started_event = QueueNodeStartedEvent(
node_execution_id="exec",
node_id="node",
node_title="title",
node_type=NodeType.LLM,
node_run_index=1,
start_at=datetime.utcnow(),
provider_type="provider",
provider_id="provider-id",
)
pipeline._workflow_response_converter.workflow_node_retry_to_stream_response = lambda **kwargs: None
assert list(pipeline._handle_node_retry_event(retry_event)) == []
pipeline._workflow_response_converter.workflow_node_retry_to_stream_response = lambda **kwargs: "retry"
assert list(pipeline._handle_node_retry_event(retry_event)) == ["retry"]
pipeline._workflow_response_converter.workflow_node_start_to_stream_response = lambda **kwargs: None
assert list(pipeline._handle_node_started_event(started_event)) == []
pipeline._workflow_response_converter.workflow_node_start_to_stream_response = lambda **kwargs: "started"
assert list(pipeline._handle_node_started_event(started_event)) == ["started"]
def test_handle_node_exception_event_saves_output(self):
pipeline = _make_pipeline()
saved_ids: list[str] = []
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "failed"
pipeline._save_output_for_event = lambda event, node_execution_id: saved_ids.append(node_execution_id)
event = QueueNodeExceptionEvent(
node_execution_id="exec-id",
node_id="node",
node_type=NodeType.START,
start_at=datetime.utcnow(),
inputs={},
outputs={},
process_data={},
error="boom",
)
responses = list(pipeline._handle_node_failed_events(event))
assert responses == ["failed"]
assert saved_ids == ["exec-id"]
def test_success_partial_and_pause_handlers(self):
pipeline = _make_pipeline()
pipeline._workflow_execution_id = "run-id"
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._workflow_response_converter.workflow_finish_to_stream_response = lambda **kwargs: "finish"
assert list(pipeline._handle_workflow_succeeded_event(QueueWorkflowSucceededEvent(outputs={}))) == ["finish"]
assert list(
pipeline._handle_workflow_partial_success_event(
QueueWorkflowPartialSuccessEvent(exceptions_count=2, outputs={})
)
) == ["finish"]
pipeline._workflow_response_converter.workflow_pause_to_stream_response = lambda **kwargs: [
"pause-a",
"pause-b",
]
pause_event = QueueWorkflowPausedEvent(reasons=[], outputs={}, paused_nodes=["node"])
assert list(pipeline._handle_workflow_paused_event(pause_event)) == ["pause-a", "pause-b"]
def test_text_chunk_handler_returns_empty_when_text_missing(self):
pipeline = _make_pipeline()
event = QueueTextChunkEvent.model_construct(text=None, from_variable_selector=None)
assert list(pipeline._handle_text_chunk_event(event)) == []
def test_dispatch_event_direct_failed_and_unhandled_paths(self):
pipeline = _make_pipeline()
pipeline._workflow_execution_id = "run-id"
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._handle_ping_event = lambda event, **kwargs: iter(["ping"])
assert list(pipeline._dispatch_event(QueuePingEvent())) == ["ping"]
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["workflow-failed"])
assert list(pipeline._dispatch_event(QueueWorkflowFailedEvent(error="failed", exceptions_count=1))) == [
"workflow-failed"
]
assert list(pipeline._dispatch_event(SimpleNamespace())) == []
def test_process_stream_response_main_match_paths_and_cleanup(self):
pipeline = _make_pipeline()
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool(system_variables=SystemVariable(workflow_execution_id="run-id")),
start_at=0.0,
)
pipeline._base_task_pipeline.queue_manager.listen = lambda: iter(
[
SimpleNamespace(event=QueueWorkflowStartedEvent()),
SimpleNamespace(event=QueueTextChunkEvent(text="hello")),
SimpleNamespace(event=QueuePingEvent()),
SimpleNamespace(event=QueueErrorEvent(error="e")),
]
)
pipeline._handle_workflow_started_event = lambda event, **kwargs: iter(["started"])
pipeline._handle_text_chunk_event = lambda event, **kwargs: iter(["text"])
pipeline._dispatch_event = lambda event, **kwargs: iter(["dispatched"])
pipeline._handle_error_event = lambda event, **kwargs: iter(["error"])
publisher_calls: list[object] = []
class _Publisher:
def publish(self, message):
publisher_calls.append(message)
responses = list(pipeline._process_stream_response(tts_publisher=_Publisher()))
assert responses == ["started", "text", "dispatched", "error"]
assert publisher_calls == [None]
def test_process_stream_response_break_paths(self):
pipeline = _make_pipeline()
pipeline._base_task_pipeline.queue_manager.listen = lambda: iter(
[SimpleNamespace(event=QueueWorkflowFailedEvent(error="fail", exceptions_count=1))]
)
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["failed"])
assert list(pipeline._process_stream_response()) == ["failed"]
pipeline._base_task_pipeline.queue_manager.listen = lambda: iter(
[SimpleNamespace(event=QueueWorkflowPausedEvent(reasons=[], outputs={}, paused_nodes=[]))]
)
pipeline._handle_workflow_paused_event = lambda event, **kwargs: iter(["paused"])
assert list(pipeline._process_stream_response()) == ["paused"]
pipeline._base_task_pipeline.queue_manager.listen = lambda: iter(
[SimpleNamespace(event=QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL))]
)
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
assert list(pipeline._process_stream_response()) == ["stopped"]
def test_save_workflow_app_log_covers_invoke_from_variants(self):
pipeline = _make_pipeline()
pipeline._user_id = "user-id"
added: list[object] = []
class _Session:
def add(self, item):
added.append(item)
pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added[-1].created_from == "installed-app"
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added[-1].created_from == "web-app"
count_before = len(added)
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert len(added) == count_before
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
assert len(added) == count_before
def test_save_output_for_event_writes_draft_variables(self, monkeypatch):
pipeline = _make_pipeline()
saver_calls: list[tuple[object, object]] = []
captured_factory_args: dict[str, object] = {}
class _Saver:
def save(self, process_data, outputs):
saver_calls.append((process_data, outputs))
def _factory(**kwargs):
captured_factory_args.update(kwargs)
return _Saver()
class _Begin:
def __enter__(self):
return None
def __exit__(self, exc_type, exc, tb):
return False
class _Session:
def __init__(self, *args, **kwargs):
_ = args, kwargs
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def begin(self):
return _Begin()
pipeline._draft_var_saver_factory = _factory
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.Session", _Session)
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
event = QueueNodeSucceededEvent(
node_execution_id="exec-id",
node_id="node-id",
node_type=NodeType.START,
in_loop_id="loop-id",
start_at=datetime.utcnow(),
process_data={"k": "v"},
outputs={"out": 1},
)
pipeline._save_output_for_event(event=event, node_execution_id="exec-id")
assert captured_factory_args["node_execution_id"] == "exec-id"
assert captured_factory_args["enclosing_node_id"] == "loop-id"
assert saver_calls == [({"k": "v"}, {"out": 1})]

View File

@@ -0,0 +1,390 @@
import base64
import queue
from unittest.mock import MagicMock
import pytest
from core.base.tts.app_generator_tts_publisher import (
AppGeneratorTTSPublisher,
AudioTrunk,
_invoice_tts,
_process_future,
)
# =========================
# Fixtures
# =========================
@pytest.fixture
def mock_model_instance(mocker):
model = mocker.MagicMock()
model.invoke_tts.return_value = [b"audio1", b"audio2"]
model.get_tts_voices.return_value = [{"value": "voice1"}, {"value": "voice2"}]
return model
@pytest.fixture
def mock_model_manager(mocker, mock_model_instance):
manager = mocker.MagicMock()
manager.get_default_model_instance.return_value = mock_model_instance
mocker.patch(
"core.base.tts.app_generator_tts_publisher.ModelManager",
return_value=manager,
)
return manager
@pytest.fixture(autouse=True)
def patch_threads(mocker):
"""Prevent real threads from starting during tests"""
mocker.patch("threading.Thread.start", return_value=None)
# =========================
# AudioTrunk Tests
# =========================
class TestAudioTrunk:
def test_audio_trunk_initialization(self):
trunk = AudioTrunk("responding", b"data")
assert trunk.status == "responding"
assert trunk.audio == b"data"
# =========================
# _invoice_tts Tests
# =========================
class TestInvoiceTTS:
@pytest.mark.parametrize(
"text",
[None, "", " "],
)
def test_invoice_tts_empty_or_none_returns_none(self, text, mock_model_instance):
result = _invoice_tts(text, mock_model_instance, "tenant", "voice1")
assert result is None
mock_model_instance.invoke_tts.assert_not_called()
def test_invoice_tts_valid_text(self, mock_model_instance):
result = _invoice_tts(" hello ", mock_model_instance, "tenant", "voice1")
mock_model_instance.invoke_tts.assert_called_once_with(
content_text="hello",
user="responding_tts",
tenant_id="tenant",
voice="voice1",
)
assert result == [b"audio1", b"audio2"]
# =========================
# _process_future Tests
# =========================
class TestProcessFuture:
def test_process_future_normal_flow(self):
future_queue = queue.Queue()
audio_queue = queue.Queue()
future = MagicMock()
future.result.return_value = [b"abc"]
future_queue.put(future)
future_queue.put(None)
_process_future(future_queue, audio_queue)
first = audio_queue.get()
assert first.status == "responding"
assert first.audio == base64.b64encode(b"abc")
finish = audio_queue.get()
assert finish.status == "finish"
def test_process_future_empty_result(self):
future_queue = queue.Queue()
audio_queue = queue.Queue()
future = MagicMock()
future.result.return_value = None
future_queue.put(future)
future_queue.put(None)
_process_future(future_queue, audio_queue)
finish = audio_queue.get()
assert finish.status == "finish"
def test_process_future_exception(self, mocker):
future_queue = queue.Queue()
audio_queue = queue.Queue()
future = MagicMock()
future.result.side_effect = Exception("error")
future_queue.put(future)
_process_future(future_queue, audio_queue)
finish = audio_queue.get()
assert finish.status == "finish"
# =========================
# AppGeneratorTTSPublisher Tests
# =========================
class TestAppGeneratorTTSPublisher:
def test_initialization_valid_voice(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
assert publisher.voice == "voice1"
assert publisher.max_sentence == 2
assert publisher.msg_text == ""
def test_initialization_invalid_voice_fallback(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "invalid_voice")
assert publisher.voice == "voice1"
def test_publish_puts_message_in_queue(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
message = MagicMock()
publisher.publish(message)
assert publisher._msg_queue.get() == message
def test_check_and_get_audio_no_audio(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
result = publisher.check_and_get_audio()
assert result is None
def test_check_and_get_audio_non_finish_event(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
trunk = AudioTrunk("responding", b"abc")
publisher._audio_queue.put(trunk)
result = publisher.check_and_get_audio()
assert result.status == "responding"
assert publisher._last_audio_event == trunk
def test_check_and_get_audio_finish_event(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
finish_trunk = AudioTrunk("finish", b"")
publisher._audio_queue.put(finish_trunk)
result = publisher.check_and_get_audio()
assert result.status == "finish"
publisher.executor.shutdown.assert_called_once()
def test_check_and_get_audio_cached_finish(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
publisher._last_audio_event = AudioTrunk("finish", b"")
result = publisher.check_and_get_audio()
assert result.status == "finish"
publisher.executor.shutdown.assert_called_once()
@pytest.mark.parametrize(
("text", "expected_sentences", "expected_remaining"),
[
("Hello world.", ["Hello world."], ""),
("Hello world! How are you?", ["Hello world!", " How are you?"], ""),
("No punctuation", [], "No punctuation"),
("", [], ""),
],
)
def test_extract_sentence(self, mock_model_manager, text, expected_sentences, expected_remaining):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
sentences, remaining = publisher._extract_sentence(text)
assert sentences == expected_sentences
assert remaining == expected_remaining
def test_runtime_handles_none_message_with_buffer(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
publisher.msg_text = "Hello."
publisher._msg_queue.put(None)
publisher._runtime()
publisher.executor.submit.assert_called_once()
def test_runtime_handles_none_message_without_buffer(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
publisher.msg_text = " "
publisher._msg_queue.put(None)
publisher._runtime()
publisher.executor.submit.assert_not_called()
def test_runtime_sentence_threshold_triggers_submit(self, mock_model_manager, mocker):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
# Force sentence extraction to hit threshold condition
mocker.patch.object(
publisher,
"_extract_sentence",
return_value=(["Hello world.", " Second sentence."], ""),
)
from core.app.entities.queue_entities import QueueTextChunkEvent
event = MagicMock()
event.event = MagicMock(spec=QueueTextChunkEvent)
event.event.text = "Hello world. Second sentence."
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.executor.submit.called
def test_runtime_handles_text_chunk_event(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueTextChunkEvent
event = MagicMock()
event.event = MagicMock(spec=QueueTextChunkEvent)
event.event.text = "Hello world."
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.executor.submit.called
def test_runtime_handles_node_succeeded_event_with_output(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueNodeSucceededEvent
event = MagicMock()
event.event = MagicMock(spec=QueueNodeSucceededEvent)
event.event.outputs = {"output": "Hello world."}
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.executor.submit.called
def test_runtime_handles_node_succeeded_event_without_output(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueNodeSucceededEvent
event = MagicMock()
event.event = MagicMock(spec=QueueNodeSucceededEvent)
event.event.outputs = None
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
publisher.executor.submit.assert_not_called()
def test_runtime_handles_agent_message_event_list_content(self, mock_model_manager, mocker):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueAgentMessageEvent
from dify_graph.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta
from dify_graph.model_runtime.entities.message_entities import (
AssistantPromptMessage,
ImagePromptMessageContent,
TextPromptMessageContent,
)
chunk = LLMResultChunk(
model="model",
delta=LLMResultChunkDelta(
index=0,
message=AssistantPromptMessage(
content=[
TextPromptMessageContent(data="Hello "),
ImagePromptMessageContent(format="png", mime_type="image/png", base64_data="a"),
]
),
),
)
event = MagicMock(event=QueueAgentMessageEvent(chunk=chunk))
mocker.patch.object(publisher, "_extract_sentence", return_value=([], ""))
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.msg_text == "Hello "
def test_runtime_handles_agent_message_event_empty_content(self, mock_model_manager, mocker):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueAgentMessageEvent
from dify_graph.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta
from dify_graph.model_runtime.entities.message_entities import AssistantPromptMessage
chunk = LLMResultChunk(
model="model",
delta=LLMResultChunkDelta(
index=0,
message=AssistantPromptMessage(content=""),
),
)
event = MagicMock(event=QueueAgentMessageEvent(chunk=chunk))
mocker.patch.object(publisher, "_extract_sentence", return_value=([], ""))
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.msg_text == ""
def test_runtime_resets_msg_text_when_text_tmp_not_str(self, mock_model_manager, mocker):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher.executor = MagicMock()
from core.app.entities.queue_entities import QueueTextChunkEvent
event = MagicMock()
event.event = MagicMock(spec=QueueTextChunkEvent)
event.event.text = "Hello world. Another sentence."
mocker.patch.object(publisher, "_extract_sentence", return_value=(["A.", "B."], None))
publisher._msg_queue.put(event)
publisher._msg_queue.put(None)
publisher._runtime()
assert publisher.msg_text == ""
def test_runtime_exception_path(self, mock_model_manager):
publisher = AppGeneratorTTSPublisher("tenant", "voice1")
publisher._msg_queue = MagicMock()
publisher._msg_queue.get.side_effect = Exception("error")
publisher._runtime()

View File

@@ -0,0 +1,197 @@
from unittest.mock import MagicMock
import pytest
import core.callback_handler.agent_tool_callback_handler as module
# -----------------------------
# Fixtures
# -----------------------------
@pytest.fixture
def enable_debug(mocker):
mocker.patch.object(module.dify_config, "DEBUG", True)
@pytest.fixture
def disable_debug(mocker):
mocker.patch.object(module.dify_config, "DEBUG", False)
@pytest.fixture
def mock_print(mocker):
return mocker.patch("builtins.print")
@pytest.fixture
def handler():
return module.DifyAgentCallbackHandler(color="blue")
# -----------------------------
# get_colored_text Tests
# -----------------------------
class TestGetColoredText:
@pytest.mark.parametrize(
("color", "expected_code"),
[
("blue", "36;1"),
("yellow", "33;1"),
("pink", "38;5;200"),
("green", "32;1"),
("red", "31;1"),
],
)
def test_get_colored_text_valid_colors(self, color, expected_code):
text = "hello"
result = module.get_colored_text(text, color)
assert expected_code in result
assert text in result
assert result.endswith("\u001b[0m")
def test_get_colored_text_invalid_color_raises(self):
with pytest.raises(KeyError):
module.get_colored_text("hello", "invalid")
def test_get_colored_text_empty_string(self):
result = module.get_colored_text("", "green")
assert "\u001b[" in result
# -----------------------------
# print_text Tests
# -----------------------------
class TestPrintText:
def test_print_text_without_color(self, mock_print):
module.print_text("hello")
mock_print.assert_called_once_with("hello", end="", file=None)
def test_print_text_with_color(self, mocker, mock_print):
mock_get_color = mocker.patch(
"core.callback_handler.agent_tool_callback_handler.get_colored_text",
return_value="colored_text",
)
module.print_text("hello", color="green")
mock_get_color.assert_called_once_with("hello", "green")
mock_print.assert_called_once_with("colored_text", end="", file=None)
def test_print_text_with_file_flush(self, mocker):
mock_file = MagicMock()
mock_print = mocker.patch("builtins.print")
module.print_text("hello", file=mock_file)
mock_print.assert_called_once_with("hello", end="", file=mock_file)
mock_file.flush.assert_called_once()
def test_print_text_with_end_parameter(self, mock_print):
module.print_text("hello", end="\n")
mock_print.assert_called_once_with("hello", end="\n", file=None)
# -----------------------------
# DifyAgentCallbackHandler Tests
# -----------------------------
class TestDifyAgentCallbackHandler:
def test_init_default_color(self):
handler = module.DifyAgentCallbackHandler()
assert handler.color == "green"
assert handler.current_loop == 1
def test_on_tool_start_debug_enabled(self, handler, enable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
handler.on_tool_start("tool1", {"a": 1})
mock_print_text.assert_called()
def test_on_tool_start_debug_disabled(self, handler, disable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
handler.on_tool_start("tool1", {"a": 1})
mock_print_text.assert_not_called()
def test_on_tool_end_debug_enabled_and_trace(self, handler, enable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
mock_trace_manager = MagicMock()
handler.on_tool_end(
tool_name="tool1",
tool_inputs={"a": 1},
tool_outputs="output",
message_id="msg1",
timer=123,
trace_manager=mock_trace_manager,
)
assert mock_print_text.call_count >= 1
mock_trace_manager.add_trace_task.assert_called_once()
def test_on_tool_end_without_trace_manager(self, handler, enable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
handler.on_tool_end(
tool_name="tool1",
tool_inputs={},
tool_outputs="output",
)
assert mock_print_text.call_count >= 1
def test_on_tool_error_debug_enabled(self, handler, enable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
handler.on_tool_error(Exception("error"))
mock_print_text.assert_called_once()
def test_on_tool_error_debug_disabled(self, handler, disable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
handler.on_tool_error(Exception("error"))
mock_print_text.assert_not_called()
@pytest.mark.parametrize("thought", ["thinking", ""])
def test_on_agent_start(self, handler, enable_debug, mocker, thought):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
handler.on_agent_start(thought)
mock_print_text.assert_called()
def test_on_agent_finish_increments_loop(self, handler, enable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
current_loop = handler.current_loop
handler.on_agent_finish()
assert handler.current_loop == current_loop + 1
mock_print_text.assert_called()
def test_on_datasource_start_debug_enabled(self, handler, enable_debug, mocker):
mock_print_text = mocker.patch("core.callback_handler.agent_tool_callback_handler.print_text")
handler.on_datasource_start("ds1", {"x": 1})
mock_print_text.assert_called_once()
def test_ignore_agent_property(self, disable_debug, handler):
assert handler.ignore_agent is True
def test_ignore_chat_model_property(self, disable_debug, handler):
assert handler.ignore_chat_model is True
def test_ignore_properties_when_debug_enabled(self, enable_debug, handler):
assert handler.ignore_agent is False
assert handler.ignore_chat_model is False

View File

@@ -0,0 +1,162 @@
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from core.callback_handler.index_tool_callback_handler import (
DatasetIndexToolCallbackHandler,
)
@pytest.fixture
def mock_queue_manager(mocker):
return mocker.Mock()
@pytest.fixture
def handler(mock_queue_manager, mocker):
mocker.patch(
"core.callback_handler.index_tool_callback_handler.db",
)
return DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
app_id="app-1",
message_id="msg-1",
user_id="user-1",
invoke_from=mocker.Mock(),
)
class TestOnQuery:
@pytest.mark.parametrize(
("invoke_from", "expected_role"),
[
(InvokeFrom.EXPLORE, "account"),
(InvokeFrom.DEBUGGER, "account"),
(InvokeFrom.WEB_APP, "end_user"),
],
)
def test_on_query_success_roles(self, mocker, mock_queue_manager, invoke_from, expected_role):
# Arrange
mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db")
handler = DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
app_id="app-1",
message_id="msg-1",
user_id="user-1",
invoke_from=mocker.Mock(),
)
handler._invoke_from = invoke_from
# Act
handler.on_query("test query", "dataset-1")
# Assert
mock_db.session.add.assert_called_once()
dataset_query = mock_db.session.add.call_args.args[0]
assert dataset_query.created_by_role == expected_role
mock_db.session.commit.assert_called_once()
def test_on_query_none_values(self, mocker, mock_queue_manager):
mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db")
handler = DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
app_id=None,
message_id=None,
user_id=None,
invoke_from=None,
)
handler.on_query(None, None)
mock_db.session.add.assert_called_once()
mock_db.session.commit.assert_called_once()
class TestOnToolEnd:
def test_on_tool_end_no_metadata(self, handler, mocker):
mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db")
document = mocker.Mock()
document.metadata = None
handler.on_tool_end([document])
mock_db.session.commit.assert_not_called()
def test_on_tool_end_dataset_document_not_found(self, handler, mocker):
mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db")
mock_db.session.scalar.return_value = None
document = mocker.Mock()
document.metadata = {"document_id": "doc-1", "doc_id": "node-1"}
handler.on_tool_end([document])
mock_db.session.scalar.assert_called_once()
def test_on_tool_end_parent_child_index_with_child(self, handler, mocker):
mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db")
mock_dataset_doc = mocker.Mock()
from core.callback_handler.index_tool_callback_handler import IndexStructureType
mock_dataset_doc.doc_form = IndexStructureType.PARENT_CHILD_INDEX
mock_dataset_doc.dataset_id = "dataset-1"
mock_dataset_doc.id = "doc-1"
mock_child_chunk = mocker.Mock()
mock_child_chunk.segment_id = "segment-1"
mock_db.session.scalar.side_effect = [mock_dataset_doc, mock_child_chunk]
document = mocker.Mock()
document.metadata = {"document_id": "doc-1", "doc_id": "node-1"}
mock_query = mocker.Mock()
mock_db.session.query.return_value = mock_query
mock_query.where.return_value = mock_query
handler.on_tool_end([document])
mock_query.update.assert_called_once()
mock_db.session.commit.assert_called_once()
def test_on_tool_end_non_parent_child_index(self, handler, mocker):
mock_db = mocker.patch("core.callback_handler.index_tool_callback_handler.db")
mock_dataset_doc = mocker.Mock()
mock_dataset_doc.doc_form = "OTHER"
mock_db.session.scalar.return_value = mock_dataset_doc
document = mocker.Mock()
document.metadata = {
"document_id": "doc-1",
"doc_id": "node-1",
"dataset_id": "dataset-1",
}
mock_query = mocker.Mock()
mock_db.session.query.return_value = mock_query
mock_query.where.return_value = mock_query
handler.on_tool_end([document])
mock_query.update.assert_called_once()
mock_db.session.commit.assert_called_once()
def test_on_tool_end_empty_documents(self, handler):
handler.on_tool_end([])
class TestReturnRetrieverResourceInfo:
def test_publish_called(self, handler, mock_queue_manager, mocker):
mock_event = mocker.patch("core.callback_handler.index_tool_callback_handler.QueueRetrieverResourcesEvent")
resources = [mocker.Mock()]
handler.return_retriever_resource_info(resources)
mock_queue_manager.publish.assert_called_once()

View File

@@ -0,0 +1,184 @@
from unittest.mock import MagicMock, call
import pytest
from core.callback_handler.workflow_tool_callback_handler import (
DifyWorkflowCallbackHandler,
)
class DummyToolInvokeMessage:
"""Lightweight dummy to simulate ToolInvokeMessage behavior."""
def __init__(self, json_value: str):
self._json_value = json_value
def model_dump_json(self):
return self._json_value
@pytest.fixture
def handler():
"""Fixture to create handler instance with deterministic color."""
instance = DifyWorkflowCallbackHandler()
instance.color = "blue"
return instance
@pytest.fixture
def mock_print_text(mocker):
"""Mock print_text to avoid real stdout printing."""
return mocker.patch("core.callback_handler.workflow_tool_callback_handler.print_text")
class TestDifyWorkflowCallbackHandler:
def test_on_tool_execution_single_output_success(self, handler, mock_print_text):
# Arrange
tool_name = "test_tool"
tool_inputs = {"a": 1}
message = DummyToolInvokeMessage('{"key": "value"}')
# Act
results = list(
handler.on_tool_execution(
tool_name=tool_name,
tool_inputs=tool_inputs,
tool_outputs=[message],
)
)
# Assert
assert results == [message]
assert mock_print_text.call_count == 4
mock_print_text.assert_has_calls(
[
call("\n[on_tool_execution]\n", color="blue"),
call("Tool: test_tool\n", color="blue"),
call(
"Outputs: " + message.model_dump_json()[:1000] + "\n",
color="blue",
),
call("\n"),
]
)
def test_on_tool_execution_multiple_outputs(self, handler, mock_print_text):
# Arrange
tool_name = "multi_tool"
outputs = [
DummyToolInvokeMessage('{"id": 1}'),
DummyToolInvokeMessage('{"id": 2}'),
]
# Act
results = list(
handler.on_tool_execution(
tool_name=tool_name,
tool_inputs={},
tool_outputs=outputs,
)
)
# Assert
assert results == outputs
assert mock_print_text.call_count == 4 * len(outputs)
def test_on_tool_execution_empty_iterable(self, handler, mock_print_text):
# Arrange
tool_name = "empty_tool"
# Act
results = list(
handler.on_tool_execution(
tool_name=tool_name,
tool_inputs={},
tool_outputs=[],
)
)
# Assert
assert results == []
mock_print_text.assert_not_called()
@pytest.mark.parametrize(
("invalid_outputs", "expected_exception"),
[
(None, TypeError),
(123, TypeError),
("not_iterable", AttributeError),
],
)
def test_on_tool_execution_invalid_outputs_type(self, handler, invalid_outputs, expected_exception):
# Arrange
tool_name = "invalid_tool"
# Act & Assert
with pytest.raises(expected_exception):
list(
handler.on_tool_execution(
tool_name=tool_name,
tool_inputs={},
tool_outputs=invalid_outputs,
)
)
def test_on_tool_execution_long_json_truncation(self, handler, mock_print_text):
# Arrange
tool_name = "long_json_tool"
long_json = "x" * 1500
message = DummyToolInvokeMessage(long_json)
# Act
list(
handler.on_tool_execution(
tool_name=tool_name,
tool_inputs={},
tool_outputs=[message],
)
)
# Assert
expected_truncated = long_json[:1000]
mock_print_text.assert_any_call(
"Outputs: " + expected_truncated + "\n",
color="blue",
)
def test_on_tool_execution_model_dump_json_exception(self, handler, mock_print_text):
# Arrange
tool_name = "exception_tool"
bad_message = MagicMock()
bad_message.model_dump_json.side_effect = ValueError("JSON error")
# Act & Assert
with pytest.raises(ValueError):
list(
handler.on_tool_execution(
tool_name=tool_name,
tool_inputs={},
tool_outputs=[bad_message],
)
)
# Ensure first two prints happened before failure
assert mock_print_text.call_count >= 2
def test_on_tool_execution_none_message_id_and_trace_manager(self, handler, mock_print_text):
# Arrange
tool_name = "optional_params_tool"
message = DummyToolInvokeMessage('{"data": "ok"}')
# Act
results = list(
handler.on_tool_execution(
tool_name=tool_name,
tool_inputs={},
tool_outputs=[message],
message_id=None,
timer=None,
trace_manager=None,
)
)
assert results == [message]
assert mock_print_text.call_count == 4

View File

@@ -0,0 +1,9 @@
from core.entities.agent_entities import PlanningStrategy
def test_planning_strategy_values_are_stable() -> None:
# Arrange / Act / Assert
assert PlanningStrategy.ROUTER.value == "router"
assert PlanningStrategy.REACT_ROUTER.value == "react_router"
assert PlanningStrategy.REACT.value == "react"
assert PlanningStrategy.FUNCTION_CALL.value == "function_call"

View File

@@ -0,0 +1,18 @@
from core.entities.document_task import DocumentTask
def test_document_task_keeps_indexing_identifiers() -> None:
# Arrange
document_ids = ("doc-1", "doc-2")
# Act
task = DocumentTask(
tenant_id="tenant-1",
dataset_id="dataset-1",
document_ids=document_ids,
)
# Assert
assert task.tenant_id == "tenant-1"
assert task.dataset_id == "dataset-1"
assert task.document_ids == document_ids

View File

@@ -0,0 +1,7 @@
from core.entities.embedding_type import EmbeddingInputType
def test_embedding_input_type_values_are_stable() -> None:
# Arrange / Act / Assert
assert EmbeddingInputType.DOCUMENT.value == "document"
assert EmbeddingInputType.QUERY.value == "query"

View File

@@ -0,0 +1,45 @@
from core.entities.execution_extra_content import (
ExecutionExtraContentDomainModel,
HumanInputContent,
HumanInputFormDefinition,
HumanInputFormSubmissionData,
)
from dify_graph.nodes.human_input.entities import FormInput, UserAction
from dify_graph.nodes.human_input.enums import FormInputType
from models.execution_extra_content import ExecutionContentType
def test_human_input_content_defaults_and_domain_alias() -> None:
# Arrange
form_definition = HumanInputFormDefinition(
form_id="form-1",
node_id="node-1",
node_title="Human Input",
form_content="Please confirm",
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="answer")],
actions=[UserAction(id="confirm", title="Confirm")],
resolved_default_values={"answer": "yes"},
expiration_time=1_700_000_000,
)
submission_data = HumanInputFormSubmissionData(
node_id="node-1",
node_title="Human Input",
rendered_content="Please confirm",
action_id="confirm",
action_text="Confirm",
)
# Act
content = HumanInputContent(
workflow_run_id="workflow-run-1",
submitted=True,
form_definition=form_definition,
form_submission_data=submission_data,
)
# Assert
assert form_definition.model_config.get("frozen") is True
assert content.type == ExecutionContentType.HUMAN_INPUT
assert content.form_definition is form_definition
assert content.form_submission_data is submission_data
assert ExecutionExtraContentDomainModel is HumanInputContent

View File

@@ -0,0 +1,45 @@
from core.entities.knowledge_entities import (
PipelineDataset,
PipelineDocument,
PipelineGenerateResponse,
)
def test_pipeline_dataset_normalizes_none_description() -> None:
# Arrange / Act
dataset = PipelineDataset(
id="dataset-1",
name="Dataset",
description=None,
chunk_structure="parent-child",
)
# Assert
assert dataset.description == ""
def test_pipeline_generate_response_builds_nested_models() -> None:
# Arrange
dataset = PipelineDataset(
id="dataset-1",
name="Dataset",
description="Knowledge base",
chunk_structure="parent-child",
)
document = PipelineDocument(
id="doc-1",
position=1,
data_source_type="file",
data_source_info={"name": "spec.pdf"},
name="spec.pdf",
indexing_status="completed",
enabled=True,
)
# Act
response = PipelineGenerateResponse(batch="batch-1", dataset=dataset, documents=[document])
# Assert
assert response.batch == "batch-1"
assert response.dataset.id == "dataset-1"
assert response.documents[0].id == "doc-1"

View File

@@ -0,0 +1,450 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from core.entities import mcp_provider as mcp_provider_module
from core.entities.mcp_provider import (
DEFAULT_EXPIRES_IN,
DEFAULT_TOKEN_TYPE,
MCPProviderEntity,
)
from core.mcp.types import OAuthTokens
def _build_mcp_provider_entity() -> MCPProviderEntity:
now = datetime(2025, 1, 1, tzinfo=UTC)
return MCPProviderEntity(
id="provider-1",
provider_id="server-1",
name="Example MCP",
tenant_id="tenant-1",
user_id="user-1",
server_url="encrypted-server-url",
headers={},
timeout=30,
sse_read_timeout=300,
authed=False,
credentials={},
tools=[],
icon={"en_US": "icon.png"},
created_at=now,
updated_at=now,
)
def test_from_db_model_maps_fields() -> None:
# Arrange
now = datetime(2025, 1, 1, tzinfo=UTC)
db_provider = SimpleNamespace(
id="provider-1",
server_identifier="server-1",
name="Example MCP",
tenant_id="tenant-1",
user_id="user-1",
server_url="encrypted-server-url",
headers={"Authorization": "enc"},
timeout=15,
sse_read_timeout=120,
authed=True,
credentials={"access_token": "enc-token"},
tool_dict=[{"name": "search"}],
icon=None,
created_at=now,
updated_at=now,
)
# Act
entity = MCPProviderEntity.from_db_model(db_provider)
# Assert
assert entity.provider_id == "server-1"
assert entity.tools == [{"name": "search"}]
assert entity.icon == ""
def test_redirect_url_uses_console_api_url(monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange
entity = _build_mcp_provider_entity()
monkeypatch.setattr(mcp_provider_module.dify_config, "CONSOLE_API_URL", "https://console.example.com")
# Act
redirect_url = entity.redirect_url
# Assert
assert redirect_url == "https://console.example.com/console/api/mcp/oauth/callback"
def test_client_metadata_for_authorization_code_flow() -> None:
# Arrange
entity = _build_mcp_provider_entity()
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value={}):
# Act
metadata = entity.client_metadata
# Assert
assert metadata.grant_types == ["refresh_token", "authorization_code"]
assert metadata.redirect_uris == [entity.redirect_url]
assert metadata.response_types == ["code"]
def test_client_metadata_for_client_credentials_flow() -> None:
# Arrange
entity = _build_mcp_provider_entity()
credentials = {"client_information": {"grant_types": ["client_credentials"]}}
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value=credentials):
# Act
metadata = entity.client_metadata
# Assert
assert metadata.grant_types == ["refresh_token", "client_credentials"]
assert metadata.redirect_uris == []
assert metadata.response_types == []
def test_client_metadata_prefers_nested_authorization_code_grant_type() -> None:
# Arrange
entity = _build_mcp_provider_entity()
credentials = {
"grant_type": "client_credentials",
"client_information": {"grant_types": ["authorization_code"]},
}
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value=credentials):
# Act
metadata = entity.client_metadata
# Assert
assert metadata.grant_types == ["refresh_token", "authorization_code"]
assert metadata.redirect_uris == [entity.redirect_url]
assert metadata.response_types == ["code"]
def test_provider_icon_returns_icon_dict_as_is() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(update={"icon": {"en_US": "icon.png"}})
# Act
icon = entity.provider_icon
# Assert
assert icon == {"en_US": "icon.png"}
def test_provider_icon_uses_signed_url_for_plain_path() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(update={"icon": "icons/mcp.png"})
with patch(
"core.entities.mcp_provider.file_helpers.get_signed_file_url",
return_value="https://signed.example.com/icons/mcp.png",
) as mock_get_signed_url:
# Act
icon = entity.provider_icon
# Assert
mock_get_signed_url.assert_called_once_with("icons/mcp.png")
assert icon == "https://signed.example.com/icons/mcp.png"
def test_to_api_response_without_sensitive_data_skips_auth_related_work() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(update={"icon": {"en_US": "icon.png"}})
with patch.object(MCPProviderEntity, "masked_server_url", return_value="https://api.example.com/******"):
# Act
response = entity.to_api_response(include_sensitive=False)
# Assert
assert response["author"] == "Anonymous"
assert response["masked_headers"] == {}
assert response["is_dynamic_registration"] is True
assert "authentication" not in response
def test_to_api_response_with_sensitive_data_includes_masked_values() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(
update={
"credentials": {"client_information": {"is_dynamic_registration": False}},
"icon": {"en_US": "icon.png"},
}
)
with patch.object(MCPProviderEntity, "masked_server_url", return_value="https://api.example.com/******"):
with patch.object(MCPProviderEntity, "masked_headers", return_value={"Authorization": "Be****"}):
with patch.object(MCPProviderEntity, "masked_credentials", return_value={"client_id": "cl****"}):
# Act
response = entity.to_api_response(user_name="Rajat", include_sensitive=True)
# Assert
assert response["author"] == "Rajat"
assert response["masked_headers"] == {"Authorization": "Be****"}
assert response["authentication"] == {"client_id": "cl****"}
assert response["is_dynamic_registration"] is False
def test_retrieve_client_information_decrypts_nested_secret() -> None:
# Arrange
entity = _build_mcp_provider_entity()
credentials = {"client_information": {"client_id": "client-1", "encrypted_client_secret": "enc-secret"}}
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value=credentials):
with patch("core.entities.mcp_provider.encrypter.decrypt_token", return_value="plain-secret") as mock_decrypt:
# Act
client_info = entity.retrieve_client_information()
# Assert
assert client_info is not None
assert client_info.client_id == "client-1"
assert client_info.client_secret == "plain-secret"
mock_decrypt.assert_called_once_with("tenant-1", "enc-secret")
def test_retrieve_client_information_returns_none_for_missing_data() -> None:
# Arrange
entity = _build_mcp_provider_entity()
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value={}):
# Act
result_empty = entity.retrieve_client_information()
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value={"client_information": "invalid"}):
# Act
result_invalid = entity.retrieve_client_information()
# Assert
assert result_empty is None
assert result_invalid is None
def test_masked_server_url_hides_path_segments() -> None:
# Arrange
entity = _build_mcp_provider_entity()
with patch.object(
MCPProviderEntity,
"decrypt_server_url",
return_value="https://api.example.com/v1/mcp?query=1",
):
# Act
masked_url = entity.masked_server_url()
# Assert
assert masked_url == "https://api.example.com/******?query=1"
def test_mask_value_covers_short_and_long_values() -> None:
# Arrange
entity = _build_mcp_provider_entity()
# Act
short_masked = entity._mask_value("short")
long_masked = entity._mask_value("abcdefghijkl")
# Assert
assert short_masked == "*****"
assert long_masked == "ab********kl"
def test_masked_headers_masks_all_decrypted_header_values() -> None:
# Arrange
entity = _build_mcp_provider_entity()
with patch.object(MCPProviderEntity, "decrypt_headers", return_value={"Authorization": "abcdefgh"}):
# Act
masked = entity.masked_headers()
# Assert
assert masked == {"Authorization": "ab****gh"}
def test_masked_credentials_handles_nested_secret_fields() -> None:
# Arrange
entity = _build_mcp_provider_entity()
credentials = {
"client_information": {
"client_id": "client-id",
"encrypted_client_secret": "encrypted-value",
"client_secret": "plain-secret",
}
}
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value=credentials):
with patch("core.entities.mcp_provider.encrypter.decrypt_token", return_value="decrypted-secret"):
# Act
masked = entity.masked_credentials()
# Assert
assert masked["client_id"] == "cl*****id"
assert masked["client_secret"] == "pl********et"
def test_masked_credentials_returns_empty_for_missing_client_information() -> None:
# Arrange
entity = _build_mcp_provider_entity()
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value={}):
# Act
masked_empty = entity.masked_credentials()
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value={"client_information": "invalid"}):
# Act
masked_invalid = entity.masked_credentials()
# Assert
assert masked_empty == {}
assert masked_invalid == {}
def test_retrieve_tokens_returns_defaults_when_optional_fields_missing() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(update={"credentials": {"token": "encrypted"}})
with patch.object(
MCPProviderEntity,
"decrypt_credentials",
return_value={"access_token": "token", "expires_in": "", "refresh_token": "refresh"},
):
# Act
tokens = entity.retrieve_tokens()
# Assert
assert isinstance(tokens, OAuthTokens)
assert tokens.access_token == "token"
assert tokens.token_type == DEFAULT_TOKEN_TYPE
assert tokens.expires_in == DEFAULT_EXPIRES_IN
assert tokens.refresh_token == "refresh"
def test_retrieve_tokens_returns_none_when_access_token_missing() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(update={"credentials": {"token": "encrypted"}})
with patch.object(MCPProviderEntity, "decrypt_credentials", return_value={"access_token": ""}) as mock_decrypt:
# Act
tokens = entity.retrieve_tokens()
# Assert
mock_decrypt.assert_called_once()
assert tokens is None
def test_decrypt_server_url_delegates_to_encrypter() -> None:
# Arrange
entity = _build_mcp_provider_entity()
with patch("core.entities.mcp_provider.encrypter.decrypt_token", return_value="https://api.example.com") as mock:
# Act
decrypted = entity.decrypt_server_url()
# Assert
mock.assert_called_once_with("tenant-1", "encrypted-server-url")
assert decrypted == "https://api.example.com"
def test_decrypt_authentication_injects_authorization_for_oauth() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(update={"authed": True, "headers": {}})
with patch.object(MCPProviderEntity, "decrypt_headers", return_value={}):
with patch.object(
MCPProviderEntity,
"retrieve_tokens",
return_value=OAuthTokens(access_token="abc123", token_type="bearer"),
):
# Act
headers = entity.decrypt_authentication()
# Assert
assert headers["Authorization"] == "Bearer abc123"
def test_decrypt_authentication_does_not_overwrite_existing_headers() -> None:
# Arrange
entity = _build_mcp_provider_entity().model_copy(
update={"authed": True, "headers": {"Authorization": "encrypted-header"}}
)
with patch.object(MCPProviderEntity, "decrypt_headers", return_value={"Authorization": "existing"}):
with patch.object(
MCPProviderEntity,
"retrieve_tokens",
return_value=OAuthTokens(access_token="abc", token_type="bearer"),
) as mock_tokens:
# Act
headers = entity.decrypt_authentication()
# Assert
mock_tokens.assert_not_called()
assert headers == {"Authorization": "existing"}
def test_decrypt_dict_returns_empty_for_empty_input() -> None:
# Arrange
entity = _build_mcp_provider_entity()
# Act
decrypted = entity._decrypt_dict({})
# Assert
assert decrypted == {}
def test_decrypt_dict_returns_original_data_when_no_encrypted_fields() -> None:
# Arrange
entity = _build_mcp_provider_entity()
input_data = {"nested": {"k": "v"}, "count": 2, "empty": ""}
# Act
result = entity._decrypt_dict(input_data)
# Assert
assert result is input_data
def test_decrypt_dict_only_decrypts_top_level_string_values() -> None:
# Arrange
entity = _build_mcp_provider_entity()
decryptor = Mock()
decryptor.decrypt.return_value = {"api_key": "plain-key"}
def _fake_create_provider_encrypter(*, tenant_id: str, config: list, cache):
assert tenant_id == "tenant-1"
assert any(item.name == "api_key" for item in config)
return decryptor, None
with patch("core.tools.utils.encryption.create_provider_encrypter", side_effect=_fake_create_provider_encrypter):
# Act
result = entity._decrypt_dict(
{
"api_key": "encrypted-key",
"nested": {"client_id": "unchanged"},
"empty": "",
"count": 2,
}
)
# Assert
decryptor.decrypt.assert_called_once_with({"api_key": "encrypted-key"})
assert result["api_key"] == "plain-key"
assert result["nested"] == {"client_id": "unchanged"}
assert result["count"] == 2
def test_decrypt_headers_and_credentials_delegate_to_decrypt_dict() -> None:
# Arrange
entity = _build_mcp_provider_entity()
with patch.object(MCPProviderEntity, "_decrypt_dict", side_effect=[{"h": "v"}, {"c": "v"}]) as mock:
# Act
headers = entity.decrypt_headers()
credentials = entity.decrypt_credentials()
# Assert
assert mock.call_count == 2
assert headers == {"h": "v"}
assert credentials == {"c": "v"}

View File

@@ -0,0 +1,92 @@
"""Unit tests for model entity behavior and invariants.
Covers DefaultModelEntity, DefaultModelProviderEntity, ModelStatus,
ProviderModelWithStatusEntity, and SimpleModelProviderEntity. Assumes i18n
labels are provided via I18nObject, model metadata aligns with FetchFrom and
ModelType expectations, and ProviderEntity/ConfigurateMethod interactions
drive provider mapping behavior.
"""
import pytest
from core.entities.model_entities import (
DefaultModelEntity,
DefaultModelProviderEntity,
ModelStatus,
ProviderModelWithStatusEntity,
SimpleModelProviderEntity,
)
from dify_graph.model_runtime.entities.common_entities import I18nObject
from dify_graph.model_runtime.entities.model_entities import FetchFrom, ModelType
from dify_graph.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
def _build_model_with_status(status: ModelStatus) -> ProviderModelWithStatusEntity:
return ProviderModelWithStatusEntity(
model="gpt-4",
label=I18nObject(en_US="GPT-4"),
model_type=ModelType.LLM,
fetch_from=FetchFrom.PREDEFINED_MODEL,
model_properties={},
status=status,
)
def test_simple_model_provider_entity_maps_from_provider_entity() -> None:
# Arrange
provider_entity = ProviderEntity(
provider="openai",
label=I18nObject(en_US="OpenAI"),
supported_model_types=[ModelType.LLM],
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
)
# Act
simple_provider = SimpleModelProviderEntity(provider_entity)
# Assert
assert simple_provider.provider == "openai"
assert simple_provider.label.en_US == "OpenAI"
assert simple_provider.supported_model_types == [ModelType.LLM]
def test_provider_model_with_status_raises_for_known_error_statuses() -> None:
# Arrange
expectations = {
ModelStatus.NO_CONFIGURE: "Model is not configured",
ModelStatus.QUOTA_EXCEEDED: "Model quota has been exceeded",
ModelStatus.NO_PERMISSION: "No permission to use this model",
ModelStatus.DISABLED: "Model is disabled",
}
for status, message in expectations.items():
# Act / Assert
with pytest.raises(ValueError, match=message):
_build_model_with_status(status).raise_for_status()
def test_provider_model_with_status_allows_active_and_credential_removed() -> None:
# Arrange
active_model = _build_model_with_status(ModelStatus.ACTIVE)
removed_model = _build_model_with_status(ModelStatus.CREDENTIAL_REMOVED)
# Act / Assert
active_model.raise_for_status()
removed_model.raise_for_status()
def test_default_model_entity_accepts_model_field_name() -> None:
# Arrange / Act
default_model = DefaultModelEntity(
model="gpt-4o-mini",
model_type=ModelType.LLM,
provider=DefaultModelProviderEntity(
provider="openai",
label=I18nObject(en_US="OpenAI"),
supported_model_types=[ModelType.LLM],
),
)
# Assert
assert default_model.model == "gpt-4o-mini"
assert default_model.provider.provider == "openai"

View File

@@ -0,0 +1,22 @@
from core.entities.parameter_entities import (
AppSelectorScope,
CommonParameterType,
ModelSelectorScope,
ToolSelectorScope,
)
def test_common_parameter_type_values_are_stable() -> None:
# Arrange / Act / Assert
assert CommonParameterType.SECRET_INPUT.value == "secret-input"
assert CommonParameterType.MODEL_SELECTOR.value == "model-selector"
assert CommonParameterType.DYNAMIC_SELECT.value == "dynamic-select"
assert CommonParameterType.ARRAY.value == "array"
assert CommonParameterType.OBJECT.value == "object"
def test_selector_scope_values_are_stable() -> None:
# Arrange / Act / Assert
assert AppSelectorScope.WORKFLOW.value == "workflow"
assert ModelSelectorScope.TEXT_EMBEDDING.value == "text-embedding"
assert ToolSelectorScope.BUILTIN.value == "builtin"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
import pytest
from core.entities.parameter_entities import AppSelectorScope
from core.entities.provider_entities import (
BasicProviderConfig,
ModelSettings,
ProviderConfig,
ProviderQuotaType,
)
from core.tools.entities.common_entities import I18nObject
from dify_graph.model_runtime.entities.model_entities import ModelType
def test_provider_quota_type_value_of_returns_enum_member() -> None:
# Arrange / Act
quota_type = ProviderQuotaType.value_of(ProviderQuotaType.TRIAL.value)
# Assert
assert quota_type == ProviderQuotaType.TRIAL
def test_provider_quota_type_value_of_rejects_unknown_values() -> None:
# Arrange / Act / Assert
with pytest.raises(ValueError, match="No matching enum found"):
ProviderQuotaType.value_of("enterprise")
def test_basic_provider_config_type_value_of_handles_known_values() -> None:
# Arrange / Act
parameter_type = BasicProviderConfig.Type.value_of("text-input")
# Assert
assert parameter_type == BasicProviderConfig.Type.TEXT_INPUT
def test_basic_provider_config_type_value_of_rejects_invalid_values() -> None:
# Arrange / Act / Assert
with pytest.raises(ValueError, match="invalid mode value"):
BasicProviderConfig.Type.value_of("unknown")
def test_provider_config_to_basic_provider_config_keeps_type_and_name() -> None:
# Arrange
provider_config = ProviderConfig(
type=BasicProviderConfig.Type.SELECT,
name="workspace",
scope=AppSelectorScope.ALL,
options=[ProviderConfig.Option(value="all", label=I18nObject(en_US="All"))],
)
# Act
basic_config = provider_config.to_basic_provider_config()
# Assert
assert isinstance(basic_config, BasicProviderConfig)
assert basic_config.type == BasicProviderConfig.Type.SELECT
assert basic_config.name == "workspace"
def test_model_settings_accepts_model_field_name() -> None:
# Arrange / Act
settings = ModelSettings(
model="gpt-4o",
model_type=ModelType.LLM,
enabled=True,
load_balancing_enabled=False,
load_balancing_configs=[],
)
# Assert
assert settings.model == "gpt-4o"
assert settings.model_type == ModelType.LLM

View File

@@ -0,0 +1,137 @@
import httpx
import pytest
from core.extension.api_based_extension_requestor import APIBasedExtensionRequestor
from models.api_based_extension import APIBasedExtensionPoint
def test_request_success(mocker):
# Mock httpx.Client and its context manager
mock_client = mocker.MagicMock()
mock_client_instance = mock_client.__enter__.return_value
mocker.patch("httpx.Client", return_value=mock_client)
mock_response = mocker.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"result": "success"}
mock_client_instance.request.return_value = mock_response
requestor = APIBasedExtensionRequestor(api_endpoint="http://example.com", api_key="test_key")
result = requestor.request(APIBasedExtensionPoint.PING, {"foo": "bar"})
assert result == {"result": "success"}
mock_client_instance.request.assert_called_once_with(
method="POST",
url="http://example.com",
json={"point": APIBasedExtensionPoint.PING.value, "params": {"foo": "bar"}},
headers={"Content-Type": "application/json", "Authorization": "Bearer test_key"},
)
def test_request_with_ssrf_proxy(mocker):
# Mock dify_config
mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080")
mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", "https://proxy:8081")
# Mock httpx.Client
mock_client = mocker.MagicMock()
mock_client_class = mocker.patch("httpx.Client", return_value=mock_client)
mock_client_instance = mock_client.__enter__.return_value
# Mock response
mock_response = mocker.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"result": "success"}
mock_client_instance.request.return_value = mock_response
# Mock HTTPTransport
mock_transport = mocker.patch("httpx.HTTPTransport")
requestor = APIBasedExtensionRequestor(api_endpoint="http://example.com", api_key="test_key")
requestor.request(APIBasedExtensionPoint.PING, {})
# Verify httpx.Client was called with mounts
mock_client_class.assert_called_once()
kwargs = mock_client_class.call_args.kwargs
assert "mounts" in kwargs
assert "http://" in kwargs["mounts"]
assert "https://" in kwargs["mounts"]
assert mock_transport.call_count == 2
def test_request_with_only_one_proxy_config(mocker):
# Mock dify_config with only one proxy
mocker.patch("configs.dify_config.SSRF_PROXY_HTTP_URL", "http://proxy:8080")
mocker.patch("configs.dify_config.SSRF_PROXY_HTTPS_URL", None)
# Mock httpx.Client
mock_client = mocker.MagicMock()
mock_client_class = mocker.patch("httpx.Client", return_value=mock_client)
mock_client_instance = mock_client.__enter__.return_value
# Mock response
mock_response = mocker.MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"result": "success"}
mock_client_instance.request.return_value = mock_response
requestor = APIBasedExtensionRequestor(api_endpoint="http://example.com", api_key="test_key")
requestor.request(APIBasedExtensionPoint.PING, {})
# Verify httpx.Client was called with mounts=None (default)
mock_client_class.assert_called_once()
kwargs = mock_client_class.call_args.kwargs
assert kwargs.get("mounts") is None
def test_request_timeout(mocker):
mock_client = mocker.MagicMock()
mock_client_instance = mock_client.__enter__.return_value
mocker.patch("httpx.Client", return_value=mock_client)
mock_client_instance.request.side_effect = httpx.TimeoutException("timeout")
requestor = APIBasedExtensionRequestor(api_endpoint="http://example.com", api_key="test_key")
with pytest.raises(ValueError, match="request timeout"):
requestor.request(APIBasedExtensionPoint.PING, {})
def test_request_connection_error(mocker):
mock_client = mocker.MagicMock()
mock_client_instance = mock_client.__enter__.return_value
mocker.patch("httpx.Client", return_value=mock_client)
mock_client_instance.request.side_effect = httpx.RequestError("error")
requestor = APIBasedExtensionRequestor(api_endpoint="http://example.com", api_key="test_key")
with pytest.raises(ValueError, match="request connection error"):
requestor.request(APIBasedExtensionPoint.PING, {})
def test_request_error_status_code(mocker):
mock_client = mocker.MagicMock()
mock_client_instance = mock_client.__enter__.return_value
mocker.patch("httpx.Client", return_value=mock_client)
mock_response = mocker.MagicMock()
mock_response.status_code = 404
mock_response.text = "Not Found"
mock_client_instance.request.return_value = mock_response
requestor = APIBasedExtensionRequestor(api_endpoint="http://example.com", api_key="test_key")
with pytest.raises(ValueError, match="request error, status_code: 404, content: Not Found"):
requestor.request(APIBasedExtensionPoint.PING, {})
def test_request_error_status_code_long_content(mocker):
mock_client = mocker.MagicMock()
mock_client_instance = mock_client.__enter__.return_value
mocker.patch("httpx.Client", return_value=mock_client)
mock_response = mocker.MagicMock()
mock_response.status_code = 500
mock_response.text = "A" * 200 # Testing truncation of content
mock_client_instance.request.return_value = mock_response
requestor = APIBasedExtensionRequestor(api_endpoint="http://example.com", api_key="test_key")
expected_content = "A" * 100
with pytest.raises(ValueError, match=f"request error, status_code: 500, content: {expected_content}"):
requestor.request(APIBasedExtensionPoint.PING, {})

View File

@@ -0,0 +1,281 @@
import json
import types
from unittest.mock import MagicMock, mock_open, patch
import pytest
from core.extension.extensible import Extensible
class TestExtensible:
def test_init(self):
tenant_id = "tenant_123"
config = {"key": "value"}
ext = Extensible(tenant_id, config)
assert ext.tenant_id == tenant_id
assert ext.config == config
@patch("core.extension.extensible.importlib.util.find_spec")
@patch("core.extension.extensible.os.path.dirname")
@patch("core.extension.extensible.os.listdir")
@patch("core.extension.extensible.os.path.isdir")
@patch("core.extension.extensible.os.path.exists")
@patch("core.extension.extensible.Path.read_text")
@patch("core.extension.extensible.importlib.util.module_from_spec")
@patch("core.extension.extensible.sort_to_dict_by_position_map")
def test_scan_extensions_success(
self,
mock_sort,
mock_module_from_spec,
mock_read_text,
mock_exists,
mock_isdir,
mock_listdir,
mock_dirname,
mock_find_spec,
):
# Setup
package_spec = MagicMock()
package_spec.origin = "/path/to/pkg/__init__.py"
module_spec = MagicMock()
module_spec.loader = MagicMock()
mock_find_spec.side_effect = [package_spec, module_spec]
mock_dirname.return_value = "/path/to/pkg"
mock_listdir.side_effect = [
["ext1"], # package_dir
["ext1.py", "__builtin__"], # subdir_path
]
mock_isdir.return_value = True
mock_exists.return_value = True
mock_read_text.return_value = "10"
# Use types.ModuleType to avoid MagicMock __dict__ issues
mock_mod = types.ModuleType("ext1")
class MockExtension(Extensible):
pass
mock_mod.MockExtension = MockExtension
mock_module_from_spec.return_value = mock_mod
mock_sort.side_effect = lambda position_map, data, name_func: data
# Execute
results = Extensible.scan_extensions()
# Assert
assert len(results) == 1
assert results[0].name == "ext1"
assert results[0].position == 10
assert results[0].builtin is True
assert results[0].extension_class == MockExtension
@patch("core.extension.extensible.importlib.util.find_spec")
def test_scan_extensions_package_not_found(self, mock_find_spec):
mock_find_spec.return_value = None
with pytest.raises(ImportError, match="Could not find package"):
Extensible.scan_extensions()
@patch("core.extension.extensible.importlib.util.find_spec")
@patch("core.extension.extensible.os.path.dirname")
@patch("core.extension.extensible.os.listdir")
@patch("core.extension.extensible.os.path.isdir")
def test_scan_extensions_skip_subdirs(self, mock_isdir, mock_listdir, mock_dirname, mock_find_spec):
package_spec = MagicMock()
package_spec.origin = "/path/to/pkg/__init__.py"
mock_find_spec.return_value = package_spec
mock_dirname.return_value = "/path/to/pkg"
mock_listdir.side_effect = [["__pycache__", "not_a_dir", "missing_py_file"], []]
mock_isdir.side_effect = [False, True]
with patch("core.extension.extensible.sort_to_dict_by_position_map", return_value=[]):
results = Extensible.scan_extensions()
assert len(results) == 0
@patch("core.extension.extensible.importlib.util.find_spec")
@patch("core.extension.extensible.os.path.dirname")
@patch("core.extension.extensible.os.listdir")
@patch("core.extension.extensible.os.path.isdir")
@patch("core.extension.extensible.os.path.exists")
@patch("core.extension.extensible.importlib.util.module_from_spec")
def test_scan_extensions_not_builtin_success(
self, mock_module_from_spec, mock_exists, mock_isdir, mock_listdir, mock_dirname, mock_find_spec
):
package_spec = MagicMock()
package_spec.origin = "/path/to/pkg/__init__.py"
module_spec = MagicMock()
module_spec.loader = MagicMock()
mock_find_spec.side_effect = [package_spec, module_spec]
mock_dirname.return_value = "/path/to/pkg"
mock_listdir.side_effect = [["ext1"], ["ext1.py", "schema.json"]]
mock_isdir.return_value = True
# exists checks: only schema.json needs to exist
mock_exists.return_value = True
mock_mod = types.ModuleType("ext1")
class MockExtension(Extensible):
pass
mock_mod.MockExtension = MockExtension
mock_module_from_spec.return_value = mock_mod
schema_content = json.dumps({"label": {"en": "Test"}, "form_schema": [{"name": "field1"}]})
with (
patch("builtins.open", mock_open(read_data=schema_content)),
patch(
"core.extension.extensible.sort_to_dict_by_position_map",
side_effect=lambda position_map, data, name_func: data,
),
):
results = Extensible.scan_extensions()
assert len(results) == 1
assert results[0].name == "ext1"
assert results[0].builtin is False
assert results[0].label == {"en": "Test"}
@patch("core.extension.extensible.importlib.util.find_spec")
@patch("core.extension.extensible.os.path.dirname")
@patch("core.extension.extensible.os.listdir")
@patch("core.extension.extensible.os.path.isdir")
@patch("core.extension.extensible.os.path.exists")
@patch("core.extension.extensible.importlib.util.module_from_spec")
def test_scan_extensions_not_builtin_missing_schema(
self, mock_module_from_spec, mock_exists, mock_isdir, mock_listdir, mock_dirname, mock_find_spec
):
package_spec = MagicMock()
package_spec.origin = "/path/to/pkg/__init__.py"
module_spec = MagicMock()
module_spec.loader = MagicMock()
mock_find_spec.side_effect = [package_spec, module_spec]
mock_dirname.return_value = "/path/to/pkg"
mock_listdir.side_effect = [["ext1"], ["ext1.py"]]
mock_isdir.return_value = True
# exists: only schema.json checked, and return False
mock_exists.return_value = False
mock_mod = types.ModuleType("ext1")
class MockExtension(Extensible):
pass
mock_mod.MockExtension = MockExtension
mock_module_from_spec.return_value = mock_mod
with patch("core.extension.extensible.sort_to_dict_by_position_map", return_value=[]):
results = Extensible.scan_extensions()
assert len(results) == 0
@patch("core.extension.extensible.importlib.util.find_spec")
@patch("core.extension.extensible.os.path.dirname")
@patch("core.extension.extensible.os.listdir")
@patch("core.extension.extensible.os.path.isdir")
@patch("core.extension.extensible.importlib.util.module_from_spec")
@patch("core.extension.extensible.os.path.exists")
def test_scan_extensions_no_extension_class(
self, mock_exists, mock_module_from_spec, mock_isdir, mock_listdir, mock_dirname, mock_find_spec
):
package_spec = MagicMock()
package_spec.origin = "/path/to/pkg/__init__.py"
module_spec = MagicMock()
module_spec.loader = MagicMock()
mock_find_spec.side_effect = [package_spec, module_spec]
mock_dirname.return_value = "/path/to/pkg"
mock_listdir.side_effect = [["ext1"], ["ext1.py"]]
mock_isdir.return_value = True
# Mock not builtin
mock_exists.return_value = False
mock_mod = types.ModuleType("ext1")
mock_mod.SomeOtherClass = type("SomeOtherClass", (), {})
mock_module_from_spec.return_value = mock_mod
# We need to ensure we don't crash if checking schema (but we won't reach there because class not found)
with patch("core.extension.extensible.sort_to_dict_by_position_map", return_value=[]):
results = Extensible.scan_extensions()
assert len(results) == 0
@patch("core.extension.extensible.importlib.util.find_spec")
@patch("core.extension.extensible.os.path.dirname")
@patch("core.extension.extensible.os.listdir")
@patch("core.extension.extensible.os.path.isdir")
def test_scan_extensions_module_import_error(self, mock_isdir, mock_listdir, mock_dirname, mock_find_spec):
package_spec = MagicMock()
package_spec.origin = "/path/to/pkg/__init__.py"
mock_find_spec.side_effect = [package_spec, None] # No module spec
mock_dirname.return_value = "/path/to/pkg"
mock_listdir.side_effect = [["ext1"], ["ext1.py"]]
mock_isdir.return_value = True
with pytest.raises(ImportError, match="Failed to load module"):
Extensible.scan_extensions()
@patch("core.extension.extensible.importlib.util.find_spec")
def test_scan_extensions_general_exception(self, mock_find_spec):
mock_find_spec.side_effect = Exception("Unexpected error")
with pytest.raises(Exception, match="Unexpected error"):
Extensible.scan_extensions()
@patch("core.extension.extensible.importlib.util.find_spec")
@patch("core.extension.extensible.os.path.dirname")
@patch("core.extension.extensible.os.listdir")
@patch("core.extension.extensible.os.path.isdir")
@patch("core.extension.extensible.os.path.exists")
@patch("core.extension.extensible.Path.read_text")
@patch("core.extension.extensible.importlib.util.module_from_spec")
def test_scan_extensions_builtin_without_position_file(
self, mock_module_from_spec, mock_read_text, mock_exists, mock_isdir, mock_listdir, mock_dirname, mock_find_spec
):
package_spec = MagicMock()
package_spec.origin = "/path/to/pkg/__init__.py"
module_spec = MagicMock()
module_spec.loader = MagicMock()
mock_find_spec.side_effect = [package_spec, module_spec]
mock_dirname.return_value = "/path/to/pkg"
mock_listdir.side_effect = [["ext1"], ["ext1.py", "__builtin__"]]
mock_isdir.return_value = True
# builtin exists in listdir, but os.path.exists(builtin_file_path) returns False
mock_exists.return_value = False
mock_mod = types.ModuleType("ext1")
class MockExtension(Extensible):
pass
mock_mod.MockExtension = MockExtension
mock_module_from_spec.return_value = mock_mod
with patch(
"core.extension.extensible.sort_to_dict_by_position_map",
side_effect=lambda position_map, data, name_func: data,
):
results = Extensible.scan_extensions()
assert len(results) == 1
assert results[0].position == 0

View File

@@ -0,0 +1,90 @@
from unittest.mock import MagicMock, patch
import pytest
from core.extension.extensible import ExtensionModule, ModuleExtension
from core.extension.extension import Extension
class TestExtension:
def setup_method(self):
# Reset the private class attribute before each test
Extension._Extension__module_extensions = {}
def test_init(self):
# Mock scan_extensions for Moderation and ExternalDataTool
mock_mod_extensions = {"mod1": ModuleExtension(name="mod1")}
mock_ext_extensions = {"ext1": ModuleExtension(name="ext1")}
extension = Extension()
# We need to mock scan_extensions on the classes defined in Extension.module_classes
with (
patch("core.extension.extension.Moderation.scan_extensions", return_value=mock_mod_extensions),
patch("core.extension.extension.ExternalDataTool.scan_extensions", return_value=mock_ext_extensions),
):
extension.init()
# Check if internal state is updated
internal_state = Extension._Extension__module_extensions
assert internal_state[ExtensionModule.MODERATION.value] == mock_mod_extensions
assert internal_state[ExtensionModule.EXTERNAL_DATA_TOOL.value] == mock_ext_extensions
def test_module_extensions_success(self):
# Setup data
mock_extensions = {"name1": ModuleExtension(name="name1"), "name2": ModuleExtension(name="name2")}
Extension._Extension__module_extensions = {ExtensionModule.MODERATION.value: mock_extensions}
extension = Extension()
result = extension.module_extensions(ExtensionModule.MODERATION.value)
assert len(result) == 2
assert any(e.name == "name1" for e in result)
assert any(e.name == "name2" for e in result)
def test_module_extensions_not_found(self):
extension = Extension()
with pytest.raises(ValueError, match="Extension Module unknown not found"):
extension.module_extensions("unknown")
def test_module_extension_success(self):
mock_ext = ModuleExtension(name="test_ext")
Extension._Extension__module_extensions = {ExtensionModule.MODERATION.value: {"test_ext": mock_ext}}
extension = Extension()
result = extension.module_extension(ExtensionModule.MODERATION, "test_ext")
assert result == mock_ext
def test_module_extension_module_not_found(self):
extension = Extension()
# ExtensionModule.MODERATION is "moderation"
with pytest.raises(ValueError, match="Extension Module moderation not found"):
extension.module_extension(ExtensionModule.MODERATION, "any")
def test_module_extension_extension_not_found(self):
# We need a non-empty dict because 'if not module_extensions' in extension.py
# returns True for an empty dict, which raises the module not found error instead.
Extension._Extension__module_extensions = {ExtensionModule.MODERATION.value: {"other": MagicMock()}}
extension = Extension()
with pytest.raises(ValueError, match="Extension unknown not found"):
extension.module_extension(ExtensionModule.MODERATION, "unknown")
def test_extension_class_success(self):
class MockClass:
pass
mock_ext = ModuleExtension(name="test_ext", extension_class=MockClass)
Extension._Extension__module_extensions = {ExtensionModule.MODERATION.value: {"test_ext": mock_ext}}
extension = Extension()
result = extension.extension_class(ExtensionModule.MODERATION, "test_ext")
assert result == MockClass
def test_extension_class_none(self):
mock_ext = ModuleExtension(name="test_ext", extension_class=None)
Extension._Extension__module_extensions = {ExtensionModule.MODERATION.value: {"test_ext": mock_ext}}
extension = Extension()
with pytest.raises(AssertionError):
extension.extension_class(ExtensionModule.MODERATION, "test_ext")

View File

@@ -0,0 +1,145 @@
from unittest.mock import MagicMock, patch
import pytest
from core.external_data_tool.api.api import ApiExternalDataTool
from models.api_based_extension import APIBasedExtensionPoint
def test_api_external_data_tool_name():
assert ApiExternalDataTool.name == "api"
@patch("core.external_data_tool.api.api.db")
def test_validate_config_success(mock_db):
mock_extension = MagicMock()
mock_extension.id = "ext_id"
mock_extension.tenant_id = "tenant_id"
mock_db.session.scalar.return_value = mock_extension
# Should not raise exception
ApiExternalDataTool.validate_config("tenant_id", {"api_based_extension_id": "ext_id"})
def test_validate_config_missing_id():
with pytest.raises(ValueError, match="api_based_extension_id is required"):
ApiExternalDataTool.validate_config("tenant_id", {})
@patch("core.external_data_tool.api.api.db")
def test_validate_config_invalid_id(mock_db):
mock_db.session.scalar.return_value = None
with pytest.raises(ValueError, match="api_based_extension_id is invalid"):
ApiExternalDataTool.validate_config("tenant_id", {"api_based_extension_id": "ext_id"})
@pytest.fixture
def api_tool():
# Use standard kwargs as it inherits from ExternalDataTool which is typically a Pydantic BaseModel
return ApiExternalDataTool(
tenant_id="tenant_id", app_id="app_id", variable="var1", config={"api_based_extension_id": "ext_id"}
)
@patch("core.external_data_tool.api.api.db")
@patch("core.external_data_tool.api.api.encrypter")
@patch("core.external_data_tool.api.api.APIBasedExtensionRequestor")
def test_query_success(mock_requestor_class, mock_encrypter, mock_db, api_tool):
mock_extension = MagicMock()
mock_extension.id = "ext_id"
mock_extension.tenant_id = "tenant_id"
mock_extension.api_endpoint = "http://api"
mock_extension.api_key = "encrypted_key"
mock_db.session.scalar.return_value = mock_extension
mock_encrypter.decrypt_token.return_value = "decrypted_key"
mock_requestor = mock_requestor_class.return_value
mock_requestor.request.return_value = {"result": "success_result"}
res = api_tool.query({"input1": "value1"}, "query_str")
assert res == "success_result"
mock_requestor_class.assert_called_once_with(api_endpoint="http://api", api_key="decrypted_key")
mock_requestor.request.assert_called_once_with(
point=APIBasedExtensionPoint.APP_EXTERNAL_DATA_TOOL_QUERY,
params={"app_id": "app_id", "tool_variable": "var1", "inputs": {"input1": "value1"}, "query": "query_str"},
)
def test_query_missing_config():
api_tool = ApiExternalDataTool(tenant_id="tenant_id", app_id="app_id", variable="var1")
api_tool.config = None # Force None
with pytest.raises(ValueError, match="config is required"):
api_tool.query({}, "")
def test_query_missing_extension_id():
api_tool = ApiExternalDataTool(tenant_id="tenant_id", app_id="app_id", variable="var1", config={"dummy": "value"})
with pytest.raises(AssertionError, match="api_based_extension_id is required"):
api_tool.query({}, "")
@patch("core.external_data_tool.api.api.db")
def test_query_invalid_extension(mock_db, api_tool):
mock_db.session.scalar.return_value = None
with pytest.raises(ValueError, match=".*error: api_based_extension_id is invalid"):
api_tool.query({}, "")
@patch("core.external_data_tool.api.api.db")
@patch("core.external_data_tool.api.api.encrypter")
@patch("core.external_data_tool.api.api.APIBasedExtensionRequestor")
def test_query_requestor_init_error(mock_requestor_class, mock_encrypter, mock_db, api_tool):
mock_extension = MagicMock()
mock_extension.id = "ext_id"
mock_extension.tenant_id = "tenant_id"
mock_extension.api_endpoint = "http://api"
mock_extension.api_key = "encrypted_key"
mock_db.session.scalar.return_value = mock_extension
mock_encrypter.decrypt_token.return_value = "decrypted_key"
mock_requestor_class.side_effect = Exception("init error")
with pytest.raises(ValueError, match=".*error: init error"):
api_tool.query({}, "")
@patch("core.external_data_tool.api.api.db")
@patch("core.external_data_tool.api.api.encrypter")
@patch("core.external_data_tool.api.api.APIBasedExtensionRequestor")
def test_query_no_result_in_response(mock_requestor_class, mock_encrypter, mock_db, api_tool):
mock_extension = MagicMock()
mock_extension.id = "ext_id"
mock_extension.tenant_id = "tenant_id"
mock_extension.api_endpoint = "http://api"
mock_extension.api_key = "encrypted_key"
mock_db.session.scalar.return_value = mock_extension
mock_encrypter.decrypt_token.return_value = "decrypted_key"
mock_requestor = mock_requestor_class.return_value
mock_requestor.request.return_value = {"other": "value"}
with pytest.raises(ValueError, match=".*error: result not found in response"):
api_tool.query({}, "")
@patch("core.external_data_tool.api.api.db")
@patch("core.external_data_tool.api.api.encrypter")
@patch("core.external_data_tool.api.api.APIBasedExtensionRequestor")
def test_query_result_not_string(mock_requestor_class, mock_encrypter, mock_db, api_tool):
mock_extension = MagicMock()
mock_extension.id = "ext_id"
mock_extension.tenant_id = "tenant_id"
mock_extension.api_endpoint = "http://api"
mock_extension.api_key = "encrypted_key"
mock_db.session.scalar.return_value = mock_extension
mock_encrypter.decrypt_token.return_value = "decrypted_key"
mock_requestor = mock_requestor_class.return_value
mock_requestor.request.return_value = {"result": 123} # Not a string
with pytest.raises(ValueError, match=".*error: result is not string"):
api_tool.query({}, "")

View File

@@ -0,0 +1,66 @@
import pytest
from core.extension.extensible import ExtensionModule
from core.external_data_tool.base import ExternalDataTool
class TestExternalDataTool:
def test_module_attribute(self):
assert ExternalDataTool.module == ExtensionModule.EXTERNAL_DATA_TOOL
def test_init(self):
# Create a concrete subclass to test init
class ConcreteTool(ExternalDataTool):
@classmethod
def validate_config(cls, tenant_id: str, config: dict):
return super().validate_config(tenant_id, config)
def query(self, inputs: dict, query: str | None = None) -> str:
return super().query(inputs, query)
tool = ConcreteTool(tenant_id="tenant_1", app_id="app_1", variable="var_1", config={"key": "value"})
assert tool.tenant_id == "tenant_1"
assert tool.app_id == "app_1"
assert tool.variable == "var_1"
assert tool.config == {"key": "value"}
def test_init_without_config(self):
# Create a concrete subclass to test init
class ConcreteTool(ExternalDataTool):
@classmethod
def validate_config(cls, tenant_id: str, config: dict):
pass
def query(self, inputs: dict, query: str | None = None) -> str:
return ""
tool = ConcreteTool(tenant_id="tenant_1", app_id="app_1", variable="var_1")
assert tool.tenant_id == "tenant_1"
assert tool.app_id == "app_1"
assert tool.variable == "var_1"
assert tool.config is None
def test_validate_config_raises_not_implemented(self):
class ConcreteTool(ExternalDataTool):
@classmethod
def validate_config(cls, tenant_id: str, config: dict):
return super().validate_config(tenant_id, config)
def query(self, inputs: dict, query: str | None = None) -> str:
return ""
with pytest.raises(NotImplementedError):
ConcreteTool.validate_config("tenant_1", {})
def test_query_raises_not_implemented(self):
class ConcreteTool(ExternalDataTool):
@classmethod
def validate_config(cls, tenant_id: str, config: dict):
pass
def query(self, inputs: dict, query: str | None = None) -> str:
return super().query(inputs, query)
tool = ConcreteTool(tenant_id="tenant_1", app_id="app_1", variable="var_1")
with pytest.raises(NotImplementedError):
tool.query({})

Some files were not shown because too many files have changed in this diff Show More