Compare commits

..
Author SHA1 Message Date
Yansong Zhang 48e95bc1c2 fix: block workflow DSL export with agent nodes 2026-07-06 17:48:26 +08:00
autofix-ci[bot]andGitHub 4f777ca6e1 [autofix.ci] apply automated fixes 2026-07-06 09:33:21 +00:00
Joel d22232d577 chore: fs download show 2026-07-06 17:27:38 +08:00
Joel ac158193e2 chore: download and preview space 2026-07-06 16:42:59 +08:00
yyh a7139e90e4 fix(agent-v2): allow file tree scroll chaining 2026-07-06 16:37:26 +08:00
Joel 1c3f5b10d9 fix: / portal place 2026-07-06 16:35:57 +08:00
zyssyz123andGitHub 4f039c218c fix(agent): avoid null agent thought stream fields (#38469) 2026-07-06 16:32:20 +08:00
yyh 7acf2a61df fix(agent-v2): open tool picker directly from add action 2026-07-06 16:28:16 +08:00
Joel 9e92e804aa chore: remove agent translate in roster 2026-07-06 16:13:21 +08:00
Joel 5eddb77fb2 chore: fix generated tooptip not show when hover 2026-07-06 16:03:35 +08:00
Joel b1c4067e71 chore: files change 2026-07-06 15:52:47 +08:00
Joel 460e380fee Improve Agent V2 configure CE notices 2026-07-06 15:52:24 +08:00
Joel 0adf95a4df feat(agent-v2): support file and skill downloads 2026-07-06 15:51:50 +08:00
Joel 6171810ba5 fix(agent-v2): keep prompt focus after slash insert 2026-07-06 15:50:58 +08:00
林玮 (Jade Lin)andGitHub c1d98f64bb fix(agent-v2): agent text history serialization (#38464) 2026-07-06 15:26:42 +08:00
yyh afdefad6bc Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-07-06 14:57:00 +08:00
autofix-ci[bot]andGitHub b2160b4f56 [autofix.ci] apply automated fixes 2026-07-06 06:42:44 +00:00
yyhandGitHub 8208b786ee docs(dify-ui): clarify radio composition stories (#38456) 2026-07-06 06:40:17 +00:00
Joel 3f0bb9ecc1 fix: not found agent to agent list and fix ts problem 2026-07-06 14:36:17 +08:00
Joel 4328231bd5 fix(agent-v2): align build note badge 2026-07-06 14:36:17 +08:00
zyssyz123andGitHub a9cd1aaaa2 fix: return friendly error for unpublished agent app (#38455) 2026-07-06 14:33:51 +08:00
Joel b62a349ab5 chore: enchance agent 404 flow 2026-07-06 14:24:43 +08:00
Joel 98b9d8feac fix(agent-v2): insert prompt references at slash cursor 2026-07-06 14:24:43 +08:00
Joel 531095e195 chroe: get agent id 404 retry 2026-07-06 14:24:43 +08:00
Joel c954b27d0a feat(agent-v2): add file row downloads 2026-07-06 14:24:42 +08:00
yyhandGitHub 4779144dc2 Merge branch 'main' into feat/agent-v2 2026-07-06 13:45:49 +08:00
yyh 5e75ffa03f test: cover agent selector option refresh 2026-07-06 13:45:13 +08:00
yyh d8bea4f889 fix: refresh agent selector options on mount 2026-07-06 13:31:29 +08:00
d9c99daf29 fix(api): isolate side-effect session writes in multimodal and RAG handlers (#38210)
Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com>
2026-07-06 05:17:12 +00:00
yyh 8e1ea9d336 test(e2e): align agent v2 publish fixtures with model guard 2026-07-06 13:16:43 +08:00
KVOJJJinandGitHub 93eb6d32b5 fix(web): update snippet placeholder icon color (#38445) 2026-07-06 05:10:36 +00:00
f8b1d9ab95 fix(agent): pass workflow files to agent node runtime (#38453)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-07-06 13:06:45 +08:00
yyh 3f314aeeaa fix(workflow): add agent v2 help link
Refs DIFY-2705
2026-07-06 13:02:26 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
de5af6c2da build(deps): bump the github-actions-dependencies group across 1 directory with 12 updates (#38430)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 04:39:10 +00:00
i晟andGitHub 586c8de1a0 refactor: drop redundant len(tag_ids)==0 check in get_target_ids_by_tag_ids (#38447) 2026-07-06 04:35:58 +00:00
zyssyz123andGitHub 0313c55638 fix(agent): require model before publishing roster agent (#38451) 2026-07-06 12:02:50 +08:00
autofix-ci[bot]andGitHub 5cf2bf7044 [autofix.ci] apply automated fixes 2026-07-06 03:43:15 +00:00
zyssyz123andGitHub e3457e013d fix(agent): allow incomplete composer draft knowledge (#38446) 2026-07-06 11:37:51 +08:00
autofix-ci[bot]andGitHub 59bc1a7fb2 [autofix.ci] apply automated fixes 2026-07-06 03:24:09 +00:00
zyssyz123andGitHub e96eb2e2d5 fix(agent): enable default file upload feature (#38441) 2026-07-06 11:17:46 +08:00
yyhandGitHub 708c962724 Merge branch 'main' into feat/agent-v2 2026-07-06 11:05:37 +08:00
yyh 0ed3a1f866 test(e2e): tighten agent v2 model guard assertion 2026-07-06 11:01:47 +08:00
yyh e0e33feabb fix(agent-v2): block missing model configure actions 2026-07-06 11:01:33 +08:00
yyh bf6eecfd75 test(e2e): cover agent v2 missing model guards 2026-07-06 10:43:58 +08:00
zyssyz123andGitHub 855e653486 fix(agent): normalize empty streaming response fields (#38439) 2026-07-06 10:29:59 +08:00
Joel 01d39e0ed4 chore: content in prompt not breakline 2026-07-06 09:32:48 +08:00
150 changed files with 3320 additions and 927 deletions
+6 -6
View File
@@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -91,13 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -142,13 +142,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@@ -73,12 +73,12 @@ jobs:
dify-agent/pyproject.toml
dify-agent/uv.lock
- if: github.event_name != 'merge_group'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
+6 -6
View File
@@ -97,7 +97,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@@ -107,7 +107,7 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env[matrix.image_name_env] }}
@@ -159,10 +159,10 @@ jobs:
file: "docker/local-sandbox/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Validate Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
push: false
context: ${{ matrix.build_context }}
@@ -197,14 +197,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |
+6 -6
View File
@@ -79,7 +79,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -123,7 +123,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -170,7 +170,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -233,7 +233,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -295,7 +295,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -351,7 +351,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 1
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+4 -4
View File
@@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
@@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
+2 -2
View File
@@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Build Docker Image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
push: false
context: ${{ matrix.context }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
+2 -2
View File
@@ -46,8 +46,8 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
outputs:
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
id: changes
with:
filters: |
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
@@ -21,10 +21,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
+8 -8
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -36,7 +36,7 @@ jobs:
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: false
python-version: "3.12"
@@ -88,7 +88,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -139,7 +139,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -168,7 +168,7 @@ jobs:
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
@@ -185,7 +185,7 @@ jobs:
- name: Save ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
@@ -196,7 +196,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -214,7 +214,7 @@ jobs:
.editorconfig
- name: Super-linter
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
if: steps.changed-files.outputs.any_changed == 'true'
env:
BASH_SEVERITY: warning
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -33,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
with:
enable-cache: true
python-version: "3.12"
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -0,0 +1,51 @@
from typing import Any, cast
from sqlalchemy import select
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from extensions.ext_database import db
from models.agent import Agent, AgentConfigSnapshot, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App
def get_published_agent_app_feature_dict_and_user_input_form(
app_model: App,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Return public Agent App parameters backed by the published Agent Soul."""
app_model_config = app_model.app_model_config
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
agent_id = app_model.bound_agent_id
if not agent_id:
raise AgentAppGeneratorError("Agent App has no bound Agent")
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.id == agent_id,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
raise AgentAppNotPublishedError("Agent has not been published")
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AgentAppGeneratorError("Agent published version not found")
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
+8 -36
View File
@@ -2,19 +2,16 @@ from typing import Any, cast
from flask_restx import Resource
from pydantic import Field
from sqlalchemy import select
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from extensions.ext_database import db
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from fields.base import ResponseModel
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from services.app_service import AppService
@@ -35,38 +32,13 @@ register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, App
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
app_model_config = app_model.app_model_config
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None or not agent.active_config_snapshot_id:
try:
return get_published_agent_app_feature_dict_and_user_input_form(app_model)
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except AgentAppGeneratorError:
raise AppUnavailableError()
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AppUnavailableError()
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
@service_api_ns.route("/parameters")
class AppParameterApi(Resource):
@@ -15,6 +15,7 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console.app.wraps import with_session
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
AgentNotPublishedError,
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
@@ -31,6 +32,7 @@ from controllers.service_api.schema import (
)
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -248,6 +250,8 @@ class CompletionApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -403,6 +407,8 @@ class ChatApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
+6
View File
@@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException):
code = 400
class AgentNotPublishedError(BaseHTTPException):
error_code = "agent_not_published"
description = "Agent has not been published. Please publish the Agent before using the API."
code = 400
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Please check if your Completion app mode matches the right API route."
+14 -3
View File
@@ -8,8 +8,10 @@ from werkzeug.exceptions import Unauthorized
from constants import HEADER_NAME_APP_CODE
from controllers.common import fields
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, AppMode, EndUser
@@ -19,7 +21,7 @@ from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService
from . import web_ns
from .error import AppUnavailableError
from .error import AgentNotPublishedError, AppUnavailableError
from .wraps import WebApiResource
logger = logging.getLogger(__name__)
@@ -74,12 +76,21 @@ class AppParameterApi(WebApiResource):
@web_ns.response(200, "Success", web_ns.models[fields.Parameters.__name__])
def get(self, app_model: App, end_user: EndUser):
"""Retrieve app parameters."""
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
features_dict: dict[str, Any]
user_input_form: list[dict[str, Any]]
if app_model.mode == AppMode.AGENT:
try:
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except AgentAppGeneratorError:
raise AppUnavailableError()
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config
+6
View File
@@ -11,6 +11,7 @@ from controllers.common.schema import register_response_schema_models, register_
from controllers.console.app.wraps import with_session
from controllers.web import web_ns
from controllers.web.error import (
AgentNotPublishedError,
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
@@ -22,6 +23,7 @@ from controllers.web.error import (
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from controllers.web.wraps import WebApiResource
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -138,6 +140,8 @@ class CompletionApi(WebApiResource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -235,6 +239,8 @@ class ChatApi(WebApiResource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except AgentAppNotPublishedError:
raise AgentNotPublishedError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
+6
View File
@@ -7,6 +7,12 @@ class AppUnavailableError(BaseHTTPException):
code = 400
class AgentNotPublishedError(BaseHTTPException):
error_code = "agent_not_published"
description = "Agent has not been published. Please publish the Agent before using the web app."
code = 400
class NotCompletionAppError(BaseHTTPException):
error_code = "not_completion_app"
description = "Please check if your Completion app mode matches the right API route."
@@ -61,10 +61,17 @@ _sensitive_word_avoidance_adapter: TypeAdapter[SensitiveWordAvoidanceConfig] = T
def _normalize_raw(raw: Any) -> Any:
if isinstance(raw, dict):
if raw.get("enabled") is None:
enabled = raw.get("enabled")
if enabled is None:
raw = {**raw, "enabled": False}
elif raw.get("enabled") is True and raw.get("config") is None:
raw = {**raw, "config": {}}
elif enabled is True:
if raw.get("config") is None:
raw = {**raw, "config": {}}
else:
# enabled is False or any falsy value —
# drop extra fields (type, config) so they don't
# violate SensitiveWordAvoidanceDisabledConfig.extra="forbid"
raw = {"enabled": False}
return raw
@@ -3,9 +3,9 @@
An Agent App has no legacy ``app_model_config``: its model / prompt live in the
bound Agent Soul snapshot. To ride the existing chat message + SSE pipeline we
synthesize an ``app_model_config``-shaped dict from the Soul (model + system
prompt) plus any app-level feature flags (opening statement, follow-up, …)
stored on ``app_model_config`` when present, then reuse the same sub-managers
the chat app type uses.
prompt) plus app-level feature flags from Agent Soul, while preserving any
legacy ``app_model_config`` feature flags when present. Then we reuse the same
sub-managers the chat app type uses.
"""
from typing import Any, cast
@@ -79,12 +79,14 @@ class AgentAppConfigManager(BaseAppConfigManager):
) -> dict[str, Any]:
"""Shape a Soul + feature flags into an ``app_model_config``-style dict.
Feature flags (opening statement / follow-up / tts / stt / citations /
moderation / annotation) come from ``app_model_config`` when present
(Q3: stored there), otherwise defaults; model + prompt always come from
Feature flags come from Agent Soul and fill gaps in the legacy
``app_model_config`` when one exists; model + prompt always come from
the Agent Soul (the single source of truth for those).
"""
base: dict[str, Any] = dict(app_model_config.to_dict()) if app_model_config else {}
soul_features = agent_soul.app_features.model_dump(mode="json", exclude_none=True)
for key, value in soul_features.items():
base.setdefault(key, value)
model = agent_soul.model
if model is not None:
+4 -5
View File
@@ -32,6 +32,7 @@ from constants import UUID_NIL
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
from core.app.apps.agent_app.app_runner import AgentAppRunner
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
@@ -64,10 +65,6 @@ from services.conversation_service import ConversationService
logger = logging.getLogger(__name__)
class AgentAppGeneratorError(ValueError):
"""Raised when an Agent App turn cannot be set up."""
def _append_prompt_file_mappings(query: str, prompt_file_mappings: Sequence[JsonValue]) -> str:
"""Append raw request file references to the backend user prompt."""
if not prompt_file_mappings:
@@ -614,6 +611,8 @@ class AgentAppGenerator(MessageBasedAppGenerator):
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
)
return agent, draft.id, config_version_kind, agent_soul
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
raise AgentAppNotPublishedError("Agent has not been published")
_, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
@@ -709,4 +708,4 @@ class AgentAppGenerator(MessageBasedAppGenerator):
return agent, draft, agent_soul
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError", "AgentAppNotPublishedError"]
+5 -5
View File
@@ -372,14 +372,14 @@ class _AgentProcessRecorder:
row = MessageAgentThought(
message_id=self._message_id,
message_chain_id=None,
thought=thought,
tool=tool,
thought=thought or "",
tool=tool or "",
tool_labels_str=_tool_labels(tool),
tool_meta_str="{}",
tool_input=tool_input,
observation=None,
tool_input=tool_input or "",
observation="",
tool_process_data=None,
message=None,
message="",
message_token=0,
message_unit_price=Decimal(0),
message_price_unit=Decimal("0.001"),
+6
View File
@@ -0,0 +1,6 @@
class AgentAppGeneratorError(ValueError):
"""Raised when an Agent App turn cannot be set up."""
class AgentAppNotPublishedError(AgentAppGeneratorError):
"""Raised when a public Agent App runtime is requested before publish."""
+7 -4
View File
@@ -5,6 +5,8 @@ from collections.abc import Generator, Mapping, Sequence
from mimetypes import guess_extension
from typing import TYPE_CHECKING, Any, Union
from sqlalchemy.orm import sessionmaker
from core.app.app_config.entities import ExternalDataVariableEntity, PromptTemplateEntity
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
@@ -423,7 +425,9 @@ class AppRunner:
_logger.exception("Failed to save image file")
return
# Create MessageFile record
# Create MessageFile record.
# Use an independent session so this side-effect write does not
# commit or close the caller's request-scoped session.
message_file = MessageFile(
message_id=message_id,
type=FileType.IMAGE,
@@ -437,9 +441,8 @@ class AppRunner:
created_by=user_id,
)
db.session.add(message_file)
db.session.commit()
db.session.refresh(message_file)
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add(message_file)
# Publish QueueMessageFileEvent
queue_manager.publish(
+2 -2
View File
@@ -122,7 +122,7 @@ class MessageStreamResponse(StreamResponse):
event: StreamEvent = StreamEvent.MESSAGE
id: str
answer: str
from_variable_selector: list[str] | None = None
from_variable_selector: list[str] = Field(default_factory=list)
class MessageAudioStreamResponse(StreamResponse):
@@ -151,7 +151,7 @@ class MessageEndStreamResponse(StreamResponse):
event: StreamEvent = StreamEvent.MESSAGE_END
id: str
metadata: Mapping[str, object] = Field(default_factory=dict)
files: Sequence[Mapping[str, Any]] | None = None
files: Sequence[Mapping[str, Any]] = Field(default_factory=list)
class MessageFileStreamResponse(StreamResponse):
@@ -1,6 +1,6 @@
import logging
import time
from collections.abc import Generator
from collections.abc import Generator, Mapping, Sequence
from threading import Thread
from typing import Any, cast
@@ -44,7 +44,7 @@ from core.app.entities.task_entities import (
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
from core.app.task_pipeline.message_file_utils import prepare_file_dict
from core.app.task_pipeline.message_file_utils import MessageFileInfoDict, prepare_file_dict
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.model_manager import ModelInstance
from core.ops.entities.trace_entity import TraceTaskName
@@ -466,10 +466,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
:return:
"""
self._task_state.metadata.usage = self._task_state.llm_result.usage
metadata_dict = self._task_state.metadata.model_dump()
metadata_dict = self._task_state.metadata.model_dump(exclude_none=True)
# Fetch files associated with this message
files = None
files: list[MessageFileInfoDict] = []
with Session(db.engine, expire_on_commit=False) as session:
message_files = session.scalars(select(MessageFile).where(MessageFile.message_id == self._message_id)).all()
@@ -492,13 +492,13 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
file_dict = prepare_file_dict(message_file, upload_files_map)
files_list.append(file_dict)
files = files_list or None
files = files_list
return MessageEndStreamResponse(
task_id=self._application_generate_entity.task_id,
id=self._message_id,
metadata=metadata_dict,
files=files,
files=cast(Sequence[Mapping[str, Any]], files),
)
def _agent_message_to_stream_response(self, answer: str, message_id: str) -> AgentMessageStreamResponse:
@@ -528,11 +528,11 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
task_id=self._application_generate_entity.task_id,
id=agent_thought.id,
position=agent_thought.position,
thought=agent_thought.thought,
observation=agent_thought.observation,
tool=agent_thought.tool,
thought=agent_thought.thought or "",
observation=agent_thought.observation or "",
tool=agent_thought.tool or "",
tool_labels=agent_thought.tool_labels,
tool_input=agent_thought.tool_input,
tool_input=agent_thought.tool_input or "",
message_files=agent_thought.files,
)
@@ -257,7 +257,7 @@ class MessageCycleManager:
task_id=self._application_generate_entity.task_id,
id=message_id,
answer=answer,
from_variable_selector=from_variable_selector,
from_variable_selector=from_variable_selector or [],
event=event_type or StreamEvent.MESSAGE,
)
@@ -2,7 +2,7 @@ import logging
from collections.abc import Sequence
from sqlalchemy import select, update
from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -10,6 +10,7 @@ from core.app.entities.queue_entities import QueueRetrieverResourcesEvent
from core.rag.entities import RetrievalSourceMetadata
from core.rag.index_processor.constant.index_type import IndexStructureType
from core.rag.models.document import Document
from extensions.ext_database import db
from models.dataset import ChildChunk, DatasetQuery, DocumentSegment
from models.dataset import Document as DatasetDocument
from models.enums import CreatorUserRole, DatasetQuerySource
@@ -46,47 +47,52 @@ class DatasetIndexToolCallbackHandler:
created_by=self._user_id,
)
session.add(dataset_query)
session.commit()
# Use an independent session so this audit-log side effect does
# not commit or close the caller's request-scoped session.
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as independent_session:
independent_session.add(dataset_query)
def on_tool_end(self, documents: list[Document], session: scoped_session):
"""Handle tool end."""
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
)
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = session.scalar(child_chunk_stmt)
if child_chunk:
session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
# Use an independent session so hit-count updates do not
# interfere with the caller's request-scoped session.
with Session(db.engine, expire_on_commit=False) as independent_session:
for document in documents:
if document.metadata is not None:
document_id = document.metadata["document_id"]
dataset_document_stmt = select(DatasetDocument).where(DatasetDocument.id == document_id)
dataset_document = independent_session.scalar(dataset_document_stmt)
if not dataset_document:
_logger.warning(
"Expected DatasetDocument record to exist, but none was found, document_id=%s",
document_id,
)
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
continue
if dataset_document.doc_form == IndexStructureType.PARENT_CHILD_INDEX:
child_chunk_stmt = select(ChildChunk).where(
ChildChunk.index_node_id == document.metadata["doc_id"],
ChildChunk.dataset_id == dataset_document.dataset_id,
ChildChunk.document_id == dataset_document.id,
)
child_chunk = independent_session.scalar(child_chunk_stmt)
if child_chunk:
independent_session.execute(
update(DocumentSegment)
.where(DocumentSegment.id == child_chunk.segment_id)
.values(hit_count=DocumentSegment.hit_count + 1)
)
else:
conditions = [DocumentSegment.index_node_id == document.metadata["doc_id"]]
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
if "dataset_id" in document.metadata:
conditions.append(DocumentSegment.dataset_id == document.metadata["dataset_id"])
# add hit count to document segment
session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
# add hit count to document segment
independent_session.execute(
update(DocumentSegment).where(*conditions).values(hit_count=DocumentSegment.hit_count + 1)
)
session.commit()
independent_session.commit()
# TODO(-LAN-): Improve type check
def return_retriever_resource_info(self, resource: Sequence[RetrievalSourceMetadata]):
@@ -48,7 +48,7 @@ from clients.agent_backend import (
)
from configs import dify_config
from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom
from core.workflow.system_variables import SystemVariableKey, get_system_text
from core.workflow.system_variables import SystemVariableKey, get_system_text, get_system_value
from graphon.file import File, FileTransferMethod
from graphon.variables.segments import Segment
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
@@ -354,17 +354,22 @@ class WorkflowAgentRuntimeRequestBuilder:
) -> str:
lines: list[str] = []
query = get_system_text(context.variable_pool, SystemVariableKey.QUERY)
uploaded_files = self._summarize_uploaded_workflow_files(context.variable_pool)
resolved_outputs = self._resolve_previous_node_outputs(
context.variable_pool,
node_job.previous_node_output_refs,
)
if not query and not resolved_outputs:
if not query and uploaded_files is None and not resolved_outputs:
return ""
lines.append("Workflow context loaded for this run:")
if query:
lines.append(f"- User query: {query}")
if uploaded_files is not None:
lines.append("- Uploaded workflow files:")
lines.append(f" - sys.files: {uploaded_files}")
if resolved_outputs:
lines.append("- Previous node outputs:")
for item in resolved_outputs:
@@ -373,6 +378,14 @@ class WorkflowAgentRuntimeRequestBuilder:
lines.append("The above workflow context is run-specific. Do not treat it as Agent Soul or persistent memory.")
return "\n".join(lines)
def _summarize_uploaded_workflow_files(self, variable_pool: VariablePoolReader) -> str | None:
files = get_system_value(variable_pool, SystemVariableKey.FILES)
if files is None:
return None
if isinstance(files, list | tuple) and not files:
return None
return self._summarize_value(files)
def _build_workflow_task_prompt(
self,
context: WorkflowAgentRuntimeBuildContext,
+27 -27
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validat
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
from core.workflow.file_reference import is_canonical_file_reference
from graphon.file import FileTransferMethod
from graphon.file import FileTransferMethod, FileType
class AgentKnowledgeQueryMode(StrEnum):
@@ -314,8 +314,9 @@ class AgentKnowledgeQueryConfig(BaseModel):
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
set owns its own query policy. Mode-dependent completeness, such as
requiring ``value`` for ``user_query``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
"""
model_config = ConfigDict(extra="forbid")
@@ -323,12 +324,6 @@ class AgentKnowledgeQueryConfig(BaseModel):
mode: AgentKnowledgeQueryMode
value: str | None = None
@model_validator(mode="after")
def validate_query(self) -> Self:
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
raise ValueError("knowledge query.value is required for user_query mode")
return self
class AgentKnowledgeModelConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -356,8 +351,9 @@ class AgentKnowledgeRetrievalConfig(BaseModel):
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
flat config. Mode-dependent completeness, such as requiring ``top_k`` for
``multiple`` or a model for ``single``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
"""
model_config = ConfigDict(extra="forbid")
@@ -371,14 +367,6 @@ class AgentKnowledgeRetrievalConfig(BaseModel):
weights: AgentKnowledgeWeightedScoreConfig | None = None
model: AgentKnowledgeModelConfig | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "multiple" and self.top_k is None:
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
if self.mode == "single" and self.model is None:
raise ValueError("knowledge retrieval.model is required for single mode")
return self
class AgentKnowledgeMetadataCondition(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -401,6 +389,8 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel):
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
Mode-dependent completeness is enforced by composer publish validation so
draft saves can persist partially configured metadata filters.
"""
model_config = ConfigDict(extra="forbid", populate_by_name=True)
@@ -410,14 +400,6 @@ class AgentKnowledgeMetadataFilteringConfig(BaseModel):
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
conditions: AgentKnowledgeMetadataConditions | None = None
@model_validator(mode="after")
def validate_mode_fields(self) -> Self:
if self.mode == "automatic" and self.metadata_model_config is None:
raise ValueError("metadata_filtering.model_config is required for automatic mode")
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
raise ValueError("metadata_filtering.conditions is required for manual mode")
return self
class AgentKnowledgeSetConfig(BaseModel):
"""One explicit knowledge set in Agent v2.
@@ -547,6 +529,23 @@ class AgentSensitiveWordAvoidanceFeatureConfig(AgentFeatureToggleConfig):
config: AgentModerationProviderConfig | None = None
class AgentFileUploadImageFeatureConfig(AgentFeatureToggleConfig):
enabled: bool = True
class AgentFileUploadFeatureConfig(AgentFeatureToggleConfig):
enabled: bool = True
allowed_file_extensions: list[str] = Field(default_factory=lambda: ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"])
allowed_file_types: list[FileType] = Field(
default_factory=lambda: [FileType.DOCUMENT, FileType.IMAGE, FileType.AUDIO, FileType.VIDEO]
)
allowed_file_upload_methods: list[FileTransferMethod] = Field(
default_factory=lambda: [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]
)
image: AgentFileUploadImageFeatureConfig = Field(default_factory=AgentFileUploadImageFeatureConfig)
number_limits: int = 3
class AgentSoulAppFeaturesConfig(AgentFlexibleConfig):
opening_statement: str | None = None
suggested_questions: list[str] | None = None
@@ -555,6 +554,7 @@ class AgentSoulAppFeaturesConfig(AgentFlexibleConfig):
text_to_speech: AgentTextToSpeechFeatureConfig | None = None
retriever_resource: AgentFeatureToggleConfig | None = None
sensitive_word_avoidance: AgentSensitiveWordAvoidanceFeatureConfig | None = None
file_upload: AgentFileUploadFeatureConfig = Field(default_factory=AgentFileUploadFeatureConfig)
class WorkflowPreviousNodeOutputRef(AgentFlexibleConfig):
+26 -4
View File
@@ -13747,6 +13747,23 @@ Stable Agent Soul reference to one normalized skill archive.
| upload_file_id | string | | No |
| url | string | | No |
#### AgentFileUploadFeatureConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allowed_file_extensions | [ string ] | | No |
| allowed_file_types | [ [FileType](#filetype) ] | | No |
| allowed_file_upload_methods | [ [FileTransferMethod](#filetransfermethod) ] | | No |
| enabled | boolean, <br>**Default:** true | | No |
| image | [AgentFileUploadImageFeatureConfig](#agentfileuploadimagefeatureconfig) | | No |
| number_limits | integer, <br>**Default:** 3 | | No |
#### AgentFileUploadImageFeatureConfig
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean, <br>**Default:** true | | No |
#### AgentHumanContactConfig
| Name | Type | Description | Required |
@@ -13890,6 +13907,8 @@ Per-set metadata filtering policy.
The Python attribute uses ``metadata_model_config`` for clarity because the
model belongs to metadata filtering specifically, while the external API and
generated schema keep the historical ``model_config`` field name via alias.
Mode-dependent completeness is enforced by composer publish validation so
draft saves can persist partially configured metadata filters.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -13912,8 +13931,9 @@ Per-set query policy for Agent v2 knowledge retrieval.
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
set owns its own query policy, so ``user_query`` must carry an explicit
``value`` while ``generated_query`` leaves that value empty.
set owns its own query policy. Mode-dependent completeness, such as
requiring ``value`` for ``user_query``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -13938,8 +13958,9 @@ set owns its own query policy, so ``user_query`` must carry an explicit
Per-set retrieval policy for Agent v2 knowledge retrieval.
Retrieval settings now live on each knowledge set instead of one shared
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
``single`` retrieval with a required model config.
flat config. Mode-dependent completeness, such as requiring ``top_k`` for
``multiple`` or a model for ``single``, is enforced by composer publish
validation so draft saves can persist partially configured knowledge sets.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
@@ -14338,6 +14359,7 @@ Visibility and lifecycle scope of an Agent record.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_upload | [AgentFileUploadFeatureConfig](#agentfileuploadfeatureconfig) | | No |
| opening_statement | string | | No |
| retriever_resource | [AgentFeatureToggleConfig](#agentfeaturetoggleconfig) | | No |
| sensitive_word_avoidance | [AgentSensitiveWordAvoidanceFeatureConfig](#agentsensitivewordavoidancefeatureconfig) | | No |
+5 -4
View File
@@ -33,6 +33,7 @@ from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import (
AgentModelNotConfiguredError,
AgentNameConflictError,
AgentNotFoundError,
AgentVersionConflictError,
@@ -168,7 +169,8 @@ class AgentComposerService:
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
if payload.save_strategy in _PUBLISH_SAVE_STRATEGIES:
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
@@ -357,7 +359,6 @@ class AgentComposerService:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
if not agent:
@@ -401,7 +402,6 @@ class AgentComposerService:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
_validate_composer_payload_for_strategy(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
return cls._save_agent_composer_for_agent(
tenant_id=tenant_id,
@@ -511,6 +511,8 @@ class AgentComposerService:
version_note=version_note,
)
)
if not agent_soul_has_model(agent_soul):
raise AgentModelNotConfiguredError()
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
version = cls._create_config_version(
tenant_id=tenant_id,
@@ -591,7 +593,6 @@ class AgentComposerService:
raise ValueError("agent_soul is required")
_backfill_cli_tool_ids(payload.agent_soul)
ComposerConfigValidator.validate_draft_save_payload(payload)
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
build_draft = cls._save_agent_draft(
tenant_id=tenant_id,
+32
View File
@@ -3,6 +3,7 @@ from typing import Any
from pydantic import ValidationError
from models.agent_config_entities import AgentKnowledgeQueryMode
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
from services.agent.prompt_mentions import (
MAX_MENTIONS_PER_PROMPT,
@@ -228,9 +229,40 @@ class ComposerConfigValidator:
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
dumped = agent_soul.model_dump(mode="json")
cls._validate_knowledge_runtime_config(agent_soul)
cls._reject_plaintext_secrets(dumped, path="agent_soul")
cls._validate_shell_config(dumped)
@classmethod
def _validate_knowledge_runtime_config(cls, agent_soul: AgentSoulConfig) -> None:
"""Validate knowledge settings that are required only for publish/run.
Draft composer saves must be able to persist partially configured
knowledge sets while a user is still editing the panel. These checks
stay in the publish validator so invalid runtime configs are still
blocked before a version can be published or executed.
"""
for knowledge_set in agent_soul.knowledge.sets:
if (
knowledge_set.query.mode == AgentKnowledgeQueryMode.USER_QUERY
and not (knowledge_set.query.value or "").strip()
):
raise InvalidComposerConfigError("knowledge query.value is required for user_query mode")
retrieval = knowledge_set.retrieval
if retrieval.mode == "multiple" and retrieval.top_k is None:
raise InvalidComposerConfigError("knowledge retrieval.top_k is required for multiple mode")
if retrieval.mode == "single" and retrieval.model is None:
raise InvalidComposerConfigError("knowledge retrieval.model is required for single mode")
metadata_filtering = knowledge_set.metadata_filtering
if metadata_filtering.mode == "automatic" and metadata_filtering.metadata_model_config is None:
raise InvalidComposerConfigError("metadata_filtering.model_config is required for automatic mode")
if metadata_filtering.mode == "manual" and (
metadata_filtering.conditions is None or not metadata_filtering.conditions.conditions
):
raise InvalidComposerConfigError("metadata_filtering.conditions is required for manual mode")
@classmethod
def validate_node_job(cls, node_job: WorkflowNodeJobConfig) -> None:
cls._reject_plaintext_secrets(node_job.model_dump(mode="json"), path="node_job")
+8
View File
@@ -1,5 +1,7 @@
from werkzeug.exceptions import BadRequest, Conflict, NotFound
from libs.exception import BaseHTTPException
class AgentNotFoundError(NotFound):
description = "Agent not found."
@@ -21,6 +23,12 @@ class AgentVersionConflictError(Conflict):
description = "Agent config version changed. Please reload and try again."
class AgentModelNotConfiguredError(BaseHTTPException):
error_code = "agent_model_not_configured"
description = "Agent App requires the Agent Soul model to be configured."
code = 400
class AgentSoulLockedError(BadRequest):
description = "Agent Soul is locked for this workflow node."
+7 -1
View File
@@ -24,6 +24,7 @@ from core.trigger.constants import (
TRIGGER_SCHEDULE_NODE_TYPE,
TRIGGER_WEBHOOK_NODE_TYPE,
)
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData
from core.workflow.nodes.trigger_schedule.trigger_schedule_node import TriggerScheduleNode
from events.app_event import app_model_config_was_updated, app_was_created
@@ -41,7 +42,7 @@ from models.model import AppModelConfig, AppModelConfigDict, IconType
from models.workflow import Workflow
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
from services.errors.app import WorkflowNotFoundError
from services.errors.app import WorkflowAgentNodeDslExportUnsupportedError, WorkflowNotFoundError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
@@ -563,6 +564,11 @@ class AppDslService:
raise WorkflowNotFoundError("Missing draft workflow configuration, please check.")
workflow_dict = workflow.to_dict(include_secret=include_secret)
if any(WorkflowAgentNodeValidator.iter_agent_v2_nodes(workflow_dict.get("graph", {}))):
raise WorkflowAgentNodeDslExportUnsupportedError(
"Workflow DSL export does not support Agent nodes yet. Remove Agent nodes before exporting."
)
# TODO: refactor: we need a better way to filter workspace related data from nodes
for node in workflow_dict.get("graph", {}).get("nodes", []):
node_data = node.get("data", {})
+1 -8
View File
@@ -3872,14 +3872,7 @@ class SegmentService:
session.add(document)
# Delete database records
session.execute(
delete(DocumentSegment).where(
DocumentSegment.id.in_(segment_db_ids),
DocumentSegment.dataset_id == dataset.id,
DocumentSegment.document_id == document.id,
DocumentSegment.tenant_id == current_user.current_tenant_id,
)
)
session.execute(delete(DocumentSegment).where(DocumentSegment.id.in_(segment_ids)))
session.commit()
@classmethod
+9
View File
@@ -14,6 +14,15 @@ class WorkflowNotFoundError(Exception):
pass
class WorkflowAgentNodeDslExportUnsupportedError(ValueError):
"""Raised when a workflow DSL export contains Agent v2 nodes.
Agent v2 node configuration is not fully portable until Agent DSL
export/import is supported, because its runtime config lives outside the
workflow graph in agent bindings and config snapshots.
"""
class WorkflowIdFormatError(Exception):
pass
+2 -2
View File
@@ -73,7 +73,7 @@ class TagService:
target must be bound to all requested tags.
"""
# Check if tag_ids is not empty to avoid WHERE false condition
if not tag_ids or len(tag_ids) == 0:
if not tag_ids:
return []
# Deduplicate repeated query params so match_all counts each requested tag once.
requested_tag_ids = list(dict.fromkeys(tag_ids))
@@ -88,7 +88,7 @@ class TagService:
return []
tag_ids = list(tags)
# Check if tag_ids is not empty to avoid WHERE false condition
if not tag_ids or len(tag_ids) == 0:
if not tag_ids:
return []
if match_all:
if len(tag_ids) != len(requested_tag_ids):
@@ -35,6 +35,7 @@ from controllers.openapi._errors import (
RecipientSurfaceMismatch,
)
from controllers.service_api.app.error import (
AgentNotPublishedError,
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
@@ -306,6 +307,7 @@ ERROR_MATRIX = [
(InternalServerError(), 500, "internal_server_error"),
(BadGateway("x"), 502, "bad_gateway"),
(AppUnavailableError(), 400, "app_unavailable"),
(AgentNotPublishedError(), 400, "agent_not_published"),
(ConversationCompletedError(), 400, "conversation_completed"),
(ProviderNotInitializeError(), 400, "provider_not_initialize"),
(ProviderQuotaExceededError(), 400, "provider_quota_exceeded"),
@@ -9,7 +9,8 @@ import pytest
from flask import Flask
from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from models.account import TenantStatus
from models.model import App, AppMode
from tests.unit_tests.conftest import setup_mock_tenant_owner_execute_result
@@ -185,6 +186,41 @@ class TestAppParameterApi:
]
mock_get_agent_parameters.assert_called_once_with(mock_app_model)
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.db")
@patch(
"controllers.service_api.app.app.get_published_agent_app_feature_dict_and_user_input_form",
side_effect=AgentAppNotPublishedError("Agent has not been published"),
)
def test_get_parameters_for_unpublished_agent_app_raises_friendly_error(
self,
mock_get_agent_parameters,
mock_db,
mock_validate_token,
mock_current_app,
mock_user_logged_in,
app: Flask,
mock_app_model,
):
_configure_current_app_mock(mock_current_app)
mock_app_model.mode = AppMode.AGENT
mock_api_token = Mock()
mock_api_token.app_id = mock_app_model.id
mock_api_token.tenant_id = mock_app_model.tenant_id
mock_validate_token.return_value = mock_api_token
mock_tenant = Mock()
mock_tenant.status = TenantStatus.NORMAL
mock_db.session.get.side_effect = [mock_app_model, mock_tenant]
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, Mock(current_tenant=mock_tenant))
with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}):
with pytest.raises(AgentNotPublishedError):
AppParameterApi().get()
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@@ -31,10 +31,12 @@ from controllers.service_api.app.completion import (
CompletionStopApi,
)
from controllers.service_api.app.error import (
AgentNotPublishedError,
AppUnavailableError,
ConversationCompletedError,
NotChatAppError,
)
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.errors.error import QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
from models.model import App, AppMode, EndUser
@@ -516,6 +518,22 @@ class TestChatApiController:
with pytest.raises(BadRequest):
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
def test_agent_not_published_error_mapped(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
AppGenerateService,
"generate",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AgentAppNotPublishedError("Agent has not been published")),
)
api = ChatApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(mode=AppMode.AGENT.value)
end_user = SimpleNamespace()
with app.test_request_context("/chat-messages", method="POST", json={"inputs": {}, "query": "hi"}):
with pytest.raises(AgentNotPublishedError):
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
class TestChatStopApiController:
def test_wrong_mode(self, app: Flask) -> None:
@@ -9,7 +9,8 @@ import pytest
from flask import Flask
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
from controllers.web.error import AppUnavailableError
from controllers.web.error import AgentNotPublishedError, AppUnavailableError
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
# ---------------------------------------------------------------------------
@@ -80,6 +81,18 @@ class TestAppParameterApi:
with pytest.raises(AppUnavailableError):
AppParameterApi().get(app_model, SimpleNamespace())
def test_agent_mode_unpublished_raises_friendly_error(self, app: Flask) -> None:
app_model = SimpleNamespace(mode="agent")
with (
app.test_request_context("/parameters"),
patch(
"controllers.web.app.get_published_agent_app_feature_dict_and_user_input_form",
side_effect=AgentAppNotPublishedError("Agent has not been published"),
),
):
with pytest.raises(AgentNotPublishedError):
AppParameterApi().get(app_model, SimpleNamespace())
# ---------------------------------------------------------------------------
# AppMeta
@@ -10,6 +10,7 @@ from flask import Flask
from controllers.web.completion import ChatApi, ChatStopApi, CompletionApi, CompletionStopApi
from controllers.web.error import (
AgentNotPublishedError,
CompletionRequestError,
NotChatAppError,
NotCompletionAppError,
@@ -17,6 +18,7 @@ from controllers.web.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
@@ -142,6 +144,19 @@ class TestChatApi:
with pytest.raises(CompletionRequestError):
ChatApi().post(_chat_app(), _end_user())
@patch(
"controllers.web.completion.AppGenerateService.generate",
side_effect=AgentAppNotPublishedError("Agent has not been published"),
)
@patch("controllers.web.completion.web_ns")
def test_agent_not_published_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
mock_ns.payload = {"inputs": {}, "query": "x"}
app_model = SimpleNamespace(id="app-1", mode="agent")
with app.test_request_context("/chat-messages", method="POST"):
with pytest.raises(AgentNotPublishedError):
ChatApi().post(app_model, _end_user())
# ---------------------------------------------------------------------------
# ChatStopApi
@@ -6,6 +6,7 @@ import pytest
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.web.error import (
AgentNotPublishedError,
AppMoreLikeThisDisabledError,
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
@@ -29,6 +30,7 @@ from controllers.web.error import (
_ERROR_SPECS: list[tuple[type, str, int]] = [
(AppUnavailableError, "app_unavailable", 400),
(AgentNotPublishedError, "agent_not_published", 400),
(NotCompletionAppError, "not_completion_app", 400),
(NotChatAppError, "not_chat_app", 400),
(NotWorkflowAppError, "not_workflow_app", 400),
@@ -65,6 +65,32 @@ def test_missing_soul_model_leaves_no_model_key():
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), None)
assert "model" not in d
assert d["pre_prompt"] == ""
assert d["file_upload"] == {
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
"allowed_file_types": ["document", "image", "audio", "video"],
"allowed_file_upload_methods": ["local_file", "remote_url"],
"enabled": True,
"image": {"enabled": True},
"number_limits": 3,
}
def test_legacy_app_model_config_file_upload_takes_precedence():
fake_amc = SimpleNamespace(
to_dict=lambda: {
"file_upload": {
"enabled": False,
"image": {"enabled": False},
},
}
)
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), fake_amc) # type: ignore[arg-type]
assert d["file_upload"] == {
"enabled": False,
"image": {"enabled": False},
}
def test_prompt_type_defaults_to_simple():
@@ -13,7 +13,7 @@ from typing import Any
import pytest
from core.app.apps.agent_app import app_generator as gen_mod
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
_SOUL_DICT = {
@@ -78,7 +78,7 @@ class TestResolveAgentById:
class TestResolveAgent:
def test_success_chains_to_resolve_by_id(self, monkeypatch: pytest.MonkeyPatch):
bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1")
bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", active_config_is_published=True)
inner_agent = SimpleNamespace(id="agent-1")
snapshot = _snapshot()
# scalar order: bound agent (in _resolve_agent), then agent + snapshot (in _resolve_agent_by_id)
@@ -97,6 +97,23 @@ class TestResolveAgent:
assert config_version_kind == "snapshot"
assert soul.model is not None
def test_unpublished_agent_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch):
bound_agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snap-1",
active_config_is_published=False,
)
_patch_session(monkeypatch, [bound_agent])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
) # type: ignore[arg-type]
def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch):
_patch_session(monkeypatch, [None])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
@@ -5,7 +5,6 @@ from uuid import uuid4
import pytest
from core.app.apps.base_app_queue_manager import PublishFrom
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 QueueMessageFileEvent
@@ -81,59 +80,55 @@ class TestBaseAppRunnerMultimodal:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory) as mock_sm:
with patch("core.app.apps.base_app_runner.db") as mock_db:
# Act
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert
mock_mgr.create_file_by_url.assert_called_once_with(
user_id=mock_user_id,
tenant_id=mock_tenant_id,
file_url=image_url,
conversation_id=None,
)
# Assert
# Verify tool file was created from URL
mock_mgr.create_file_by_url.assert_called_once_with(
user_id=mock_user_id,
tenant_id=mock_tenant_id,
file_url=image_url,
conversation_id=None,
)
mock_msg_file_class.assert_called_once()
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["message_id"] == mock_message_id
assert call_kwargs["type"] == FileType.IMAGE
assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE
assert call_kwargs["belongs_to"] == "assistant"
assert call_kwargs["created_by"] == mock_user_id
# Verify message file was created with correct parameters
mock_msg_file_class.assert_called_once()
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["message_id"] == mock_message_id
assert call_kwargs["type"] == FileType.IMAGE
assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE
assert call_kwargs["belongs_to"] == "assistant"
assert call_kwargs["created_by"] == mock_user_id
# Verify independent session was used (not db.session)
mock_sm.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
file_session.add.assert_called_once_with(mock_message_file)
mock_db.session.commit.assert_not_called()
mock_db.session.close.assert_not_called()
# Verify database operations
mock_session.add.assert_called_once_with(mock_message_file)
mock_session.commit.assert_called_once()
mock_session.refresh.assert_called_once_with(mock_message_file)
# Verify event was published
mock_queue_manager.publish.assert_called_once()
publish_call = mock_queue_manager.publish.call_args
assert isinstance(publish_call[0][0], QueueMessageFileEvent)
assert publish_call[0][0].message_file_id == mock_message_file.id
# publish_from might be passed as positional or keyword argument
assert (
publish_call[0][1] == PublishFrom.APPLICATION_MANAGER
or publish_call.kwargs.get("publish_from") == PublishFrom.APPLICATION_MANAGER
)
# Verify event was published
mock_queue_manager.publish.assert_called_once()
publish_call = mock_queue_manager.publish.call_args
assert isinstance(publish_call[0][0], QueueMessageFileEvent)
assert publish_call[0][0].message_file_id == mock_message_file.id
def test_handle_multimodal_image_content_with_base64(
self,
@@ -165,50 +160,44 @@ class TestBaseAppRunnerMultimodal:
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db") as mock_db:
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert
# Verify tool file was created from base64
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
assert call_kwargs["user_id"] == mock_user_id
assert call_kwargs["tenant_id"] == mock_tenant_id
assert call_kwargs["conversation_id"] is None
assert "file_binary" in call_kwargs
assert call_kwargs["mimetype"] == "image/png"
assert call_kwargs["filename"].startswith("generated_image")
assert call_kwargs["filename"].endswith(".png")
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
assert call_kwargs["user_id"] == mock_user_id
assert call_kwargs["tenant_id"] == mock_tenant_id
assert call_kwargs["conversation_id"] is None
assert "file_binary" in call_kwargs
assert call_kwargs["mimetype"] == "image/png"
assert call_kwargs["filename"].startswith("generated_image")
assert call_kwargs["filename"].endswith(".png")
# Verify message file was created
mock_msg_file_class.assert_called_once()
mock_msg_file_class.assert_called_once()
file_session.add.assert_called_once()
mock_db.session.commit.assert_not_called()
# Verify database operations
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
mock_session.refresh.assert_called_once()
# Verify event was published
mock_queue_manager.publish.assert_called_once()
mock_queue_manager.publish.assert_called_once()
def test_handle_multimodal_image_content_with_base64_data_uri(
self,
@@ -238,33 +227,32 @@ class TestBaseAppRunnerMultimodal:
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert - verify that base64 data was extracted correctly (without prefix)
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
# The base64 data should be decoded, so we check the binary was passed
assert "file_binary" in call_kwargs
mock_mgr.create_file_by_raw.assert_called_once()
call_kwargs = mock_mgr.create_file_by_raw.call_args[1]
assert "file_binary" in call_kwargs
def test_handle_multimodal_image_content_without_url_or_base64(
self,
@@ -284,9 +272,7 @@ class TestBaseAppRunnerMultimodal:
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
# Act
# Create a mock runner with the method bound
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
@@ -299,10 +285,8 @@ class TestBaseAppRunnerMultimodal:
queue_manager=mock_queue_manager,
)
# Assert - should not create any files or publish events
mock_mgr_class.assert_not_called()
mock_msg_file_class.assert_not_called()
mock_session.add.assert_not_called()
mock_queue_manager.publish.assert_not_called()
def test_handle_multimodal_image_content_with_error(
@@ -322,20 +306,16 @@ class TestBaseAppRunnerMultimodal:
)
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
# Setup mock to raise exception
mock_mgr = MagicMock()
mock_mgr.create_file_by_url.side_effect = Exception("Network error")
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
# Act
# Create a mock runner with the method bound
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
# Should not raise exception, just log it
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
@@ -344,9 +324,7 @@ class TestBaseAppRunnerMultimodal:
queue_manager=mock_queue_manager,
)
# Assert - should not create message file or publish event on error
mock_msg_file_class.assert_not_called()
mock_session.add.assert_not_called()
mock_queue_manager.publish.assert_not_called()
def test_handle_multimodal_image_content_debugger_mode(
@@ -369,37 +347,36 @@ class TestBaseAppRunnerMultimodal:
mock_queue_manager.invoke_from = InvokeFrom.DEBUGGER
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
# Setup mock tool file manager
mock_mgr = MagicMock()
mock_mgr.create_file_by_url.return_value = mock_tool_file
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert - verify created_by_role is ACCOUNT for debugger mode
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT
def test_handle_multimodal_image_content_service_api_mode(
self,
@@ -421,34 +398,33 @@ class TestBaseAppRunnerMultimodal:
mock_queue_manager.invoke_from = InvokeFrom.SERVICE_API
with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class:
# Setup mock tool file manager
mock_mgr = MagicMock()
mock_mgr.create_file_by_url.return_value = mock_tool_file
mock_mgr_class.return_value = mock_mgr
with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class:
# Setup mock message file
mock_msg_file_class.return_value = mock_message_file
with patch("core.app.apps.base_app_runner.db.session", autospec=True) as mock_session:
mock_session.add = MagicMock()
mock_session.commit = MagicMock()
mock_session.refresh = MagicMock()
file_session = MagicMock()
mock_session_factory = MagicMock()
mock_session_factory.begin.return_value.__enter__ = MagicMock(return_value=file_session)
mock_session_factory.begin.return_value.__exit__ = MagicMock(return_value=False)
# Act
# Create a mock runner with the method bound
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs)
with patch("core.app.apps.base_app_runner.sessionmaker", return_value=mock_session_factory):
with patch("core.app.apps.base_app_runner.db"):
runner = MagicMock()
method = AppRunner._handle_multimodal_image_content
runner._handle_multimodal_image_content = lambda *args, **kwargs: method(
runner, *args, **kwargs
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
runner._handle_multimodal_image_content(
content=content,
message_id=mock_message_id,
user_id=mock_user_id,
tenant_id=mock_tenant_id,
queue_manager=mock_queue_manager,
)
# Assert - verify created_by_role is END_USER for service API
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER
call_kwargs = mock_msg_file_class.call_args[1]
assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER
@@ -721,6 +721,56 @@ class TestEasyUiBasedGenerateTaskPipeline:
assert response is not None
assert response.id == "thought"
def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, monkeypatch: pytest.MonkeyPatch):
conversation = _make_conversation(AppMode.CHAT)
message = _make_message()
pipeline = EasyUIBasedGenerateTaskPipeline(
application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT),
queue_manager=_FakeQueueManager(),
conversation=conversation,
message=message,
stream=True,
)
agent_thought = _agent_thought()
agent_thought.thought = None
agent_thought.observation = None
agent_thought.tool = None
agent_thought.tool_input = None
agent_thought.message_files = None
class _Session:
def __init__(self, *args, **kwargs):
pass
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def scalar(self, *args, **kwargs):
return agent_thought
monkeypatch.setattr(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session",
_Session,
)
monkeypatch.setattr(
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db",
_FakeDb(),
)
response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="thought"))
assert response is not None
assert response.thought == ""
assert response.observation == ""
assert response.tool == ""
assert response.tool_input == ""
assert response.model_dump(mode="json")["message_files"] == []
def test_process_routes_to_stream_and_starts_conversation_name_generation(self):
conversation = _make_conversation(AppMode.CHAT)
message = _make_message()
@@ -1280,7 +1330,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
usage_metadata = cast(dict[str, object], response.metadata["usage"])
assert usage_metadata["prompt_tokens"] == 1
def test_record_files_returns_none_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch):
def test_record_files_returns_empty_list_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch):
conversation = _make_conversation(AppMode.CHAT)
message = _make_message()
pipeline = EasyUIBasedGenerateTaskPipeline(
@@ -1316,7 +1366,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
response = pipeline._message_end_to_stream_response()
assert response.files is None
assert response.files == []
def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch: pytest.MonkeyPatch):
conversation = _make_conversation(AppMode.CHAT)
@@ -6,7 +6,7 @@ SSE event, which is critical for vision/image chat responses to render correctly
Test Coverage:
- Files array populated when MessageFile records exist
- Files array is None when no MessageFile records exist
- Files array is empty when no MessageFile records exist
- Correct signed URL generation for LOCAL_FILE transfer method
- Correct URL handling for REMOTE_URL transfer method
- Correct URL handling for TOOL_FILE transfer method
@@ -90,7 +90,7 @@ class TestMessageEndStreamResponseFiles:
return upload_file
def test_message_end_with_no_files(self, mock_pipeline):
"""Test that files array is None when no MessageFile records exist."""
"""Test that files array is empty when no MessageFile records exist."""
# Arrange
with (
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
@@ -108,9 +108,10 @@ class TestMessageEndStreamResponseFiles:
# Assert
assert isinstance(result, MessageEndStreamResponse)
assert result.files is None
assert result.files == []
assert result.id == mock_pipeline._message_id
assert result.metadata == {"test": "metadata"}
mock_pipeline._task_state.metadata.model_dump.assert_called_once_with(exclude_none=True)
def test_message_end_with_local_file(self, mock_pipeline, mock_message_file_local, mock_upload_file):
"""Test that files array is populated correctly for LOCAL_FILE transfer method."""
@@ -14,6 +14,9 @@ def mock_queue_manager(mocker: MockerFixture):
@pytest.fixture
def handler(mock_queue_manager, mocker: MockerFixture):
mocker.patch(
"core.callback_handler.index_tool_callback_handler.db",
)
return DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
app_id="app-1",
@@ -33,8 +36,18 @@ class TestOnQuery:
],
)
def test_on_query_success_roles(self, mocker: MockerFixture, mock_queue_manager, invoke_from, expected_role):
# Arrange
mock_session = mocker.Mock()
# Arrange — the caller passes a session, but our fix uses an independent one
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mock_session_factory = mocker.MagicMock()
mock_session_factory.begin.return_value.__enter__ = mocker.MagicMock(return_value=independent_session)
mock_session_factory.begin.return_value.__exit__ = mocker.MagicMock(return_value=False)
mocker.patch(
"core.callback_handler.index_tool_callback_handler.sessionmaker",
return_value=mock_session_factory,
)
mocker.patch("core.callback_handler.index_tool_callback_handler.db")
handler = DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
@@ -46,17 +59,28 @@ class TestOnQuery:
handler._invoke_from = invoke_from
# Act
handler.on_query("test query", "dataset-1", mock_session)
# Act — pass caller_session as required by signature
handler.on_query("test query", "dataset-1", caller_session)
# Assert
mock_session.add.assert_called_once()
dataset_query = mock_session.add.call_args.args[0]
# Assert — independent session used, not the caller's session
independent_session.add.assert_called_once()
dataset_query = independent_session.add.call_args.args[0]
assert dataset_query.created_by_role == expected_role
mock_session.commit.assert_called_once()
caller_session.add.assert_not_called()
caller_session.commit.assert_not_called()
def test_on_query_none_values(self, mocker: MockerFixture, mock_queue_manager):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mock_session_factory = mocker.MagicMock()
mock_session_factory.begin.return_value.__enter__ = mocker.MagicMock(return_value=independent_session)
mock_session_factory.begin.return_value.__exit__ = mocker.MagicMock(return_value=False)
mocker.patch(
"core.callback_handler.index_tool_callback_handler.sessionmaker",
return_value=mock_session_factory,
)
mocker.patch("core.callback_handler.index_tool_callback_handler.db")
handler = DatasetIndexToolCallbackHandler(
queue_manager=mock_queue_manager,
@@ -66,40 +90,67 @@ class TestOnQuery:
invoke_from=None,
)
handler.on_query(None, None, mock_session)
handler.on_query(None, None, caller_session)
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
independent_session.add.assert_called_once()
caller_session.add.assert_not_called()
class TestOnToolEnd:
def test_on_tool_end_no_metadata(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
document = mocker.Mock()
document.metadata = None
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.commit.assert_not_called()
independent_session.commit.assert_called_once()
independent_session.execute.assert_not_called()
caller_session.commit.assert_not_called()
def test_on_tool_end_dataset_document_not_found(
self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture
):
mock_session = mocker.Mock()
mock_session.scalar.return_value = None
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
independent_session.scalar.return_value = None
document = mocker.Mock()
document.metadata = {"document_id": "doc-1", "doc_id": "node-1"}
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.scalar.assert_called_once()
independent_session.scalar.assert_called_once()
caller_session.scalar.assert_not_called()
def test_on_tool_end_parent_child_index_with_child(
self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture
):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
mock_dataset_doc = mocker.Mock()
from core.callback_handler.index_tool_callback_handler import IndexStructureType
@@ -111,23 +162,32 @@ class TestOnToolEnd:
mock_child_chunk = mocker.Mock()
mock_child_chunk.segment_id = "segment-1"
mock_session.scalar.side_effect = [mock_dataset_doc, mock_child_chunk]
independent_session.scalar.side_effect = [mock_dataset_doc, mock_child_chunk]
document = mocker.Mock()
document.metadata = {"document_id": "doc-1", "doc_id": "node-1"}
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.execute.assert_called_once()
mock_session.commit.assert_called_once()
independent_session.execute.assert_called_once()
independent_session.commit.assert_called_once()
caller_session.execute.assert_not_called()
def test_on_tool_end_non_parent_child_index(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture):
mock_session = mocker.Mock()
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
mock_dataset_doc = mocker.Mock()
mock_dataset_doc.doc_form = "OTHER"
mock_session.scalar.return_value = mock_dataset_doc
independent_session.scalar.return_value = mock_dataset_doc
document = mocker.Mock()
document.metadata = {
@@ -136,14 +196,24 @@ class TestOnToolEnd:
"dataset_id": "dataset-1",
}
handler.on_tool_end([document], mock_session)
handler.on_tool_end([document], caller_session)
mock_session.execute.assert_called_once()
mock_session.commit.assert_called_once()
independent_session.execute.assert_called_once()
independent_session.commit.assert_called_once()
caller_session.execute.assert_not_called()
def test_on_tool_end_empty_documents(self, handler: DatasetIndexToolCallbackHandler, mocker: MockerFixture):
mock_session = mocker.Mock()
handler.on_tool_end([], mock_session)
caller_session = mocker.Mock()
independent_session = mocker.MagicMock()
mocker.patch(
"core.callback_handler.index_tool_callback_handler.Session",
return_value=independent_session,
)
independent_session.__enter__ = mocker.MagicMock(return_value=independent_session)
independent_session.__exit__ = mocker.MagicMock(return_value=False)
handler.on_tool_end([], caller_session)
class TestReturnRetrieverResourceInfo:
@@ -227,6 +227,15 @@ def _previous_node_prompt_payload(result, selector: str) -> object:
raise AssertionError(f"missing prompt payload for {selector}")
def _uploaded_workflow_files_prompt_payload(result) -> object:
prefix = " - sys.files: "
user_prompt = _workflow_user_prompt(result)
for line in user_prompt.splitlines():
if line.startswith(prefix):
return json.loads(line.removeprefix(prefix))
raise AssertionError("missing prompt payload for sys.files")
def test_builds_create_run_request_from_agent_soul_and_node_job():
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
@@ -1252,6 +1261,48 @@ def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_
]
def test_uploaded_workflow_files_are_included_without_prompt_marker():
file_reference = build_file_reference(record_id="uploaded-file-1")
class UploadedFilesVariablePool(FakeVariablePool):
def get(self, selector):
if list(selector) == ["sys", "files"]:
return ArrayFileSegment(
value=[
File(
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
reference=file_reference,
remote_url=None,
filename="requirements.pdf",
extension=".pdf",
mime_type="application/pdf",
size=12,
)
]
)
return super().get(selector)
context = replace(_context(), variable_pool=UploadedFilesVariablePool())
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "Answer the user's question.",
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
user_prompt = _workflow_user_prompt(result)
assert "- Uploaded workflow files:" in user_prompt
assert _uploaded_workflow_files_prompt_payload(result) == [
{
"transfer_method": "local_file",
"reference": file_reference,
}
]
assert "Previous node outputs:" not in user_prompt
def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context():
remote_url = "https://example.com/" + ("a" * 2100) + ".pdf"
@@ -14,6 +14,23 @@ from services.entities.agent_entities import (
)
def test_default_agent_soul_enables_file_upload_feature():
agent_soul = AgentSoulConfig()
file_upload = agent_soul.model_dump(mode="json")["app_features"]["file_upload"]
assert file_upload == {
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
"allowed_file_types": ["document", "image", "audio", "video"],
"allowed_file_upload_methods": ["local_file", "remote_url"],
"enabled": True,
"image": {"enabled": True},
"number_limits": 3,
}
# The product default should be visible in API responses, but it must not
# make workflow-only payload validation treat app_features as user-authored.
assert bool(agent_soul.app_features) is False
def test_workflow_variant_rejects_agent_app_only_fields():
with pytest.raises(ValueError):
ComposerSavePayload.model_validate(
@@ -257,6 +274,16 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
},
"knowledge set dataset ids must be unique",
),
],
)
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
with pytest.raises(ValidationError, match=match):
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
@pytest.mark.parametrize(
("knowledge_payload", "match"),
[
(
{
"sets": [
@@ -317,9 +344,25 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
),
],
)
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
with pytest.raises(ValidationError, match=match):
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
def test_knowledge_runtime_requirements_block_publish_but_not_draft_save(knowledge_payload, match: str):
draft_payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
"agent_soul": {"knowledge": knowledge_payload},
}
)
ComposerConfigValidator.validate_draft_save_payload(draft_payload)
publish_payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP,
"save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
"agent_soul": {"knowledge": knowledge_payload},
}
)
with pytest.raises(InvalidComposerConfigError, match=match):
ComposerConfigValidator.validate_publish_payload(publish_payload)
def test_agent_soul_model_config_is_first_class_without_credentials():
@@ -36,6 +36,7 @@ from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import (
AgentModelNotConfiguredError,
AgentNameConflictError,
AgentNotFoundError,
AgentVersionConflictError,
@@ -576,6 +577,55 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps
assert fake_session.commits == 1
def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.MonkeyPatch):
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Iris",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-1",
active_config_is_published=False,
)
draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
base_snapshot_id="version-1",
config_snapshot=AgentSoulConfig(),
)
fake_session = FakeSession(scalar=[agent, draft])
def fail_create_config_version(**_kwargs):
raise AssertionError("config version must not be created when Agent Soul has no model")
def fail_validate_knowledge_datasets(**_kwargs):
raise AssertionError("knowledge datasets must not be validated when Agent Soul has no model")
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", fail_validate_knowledge_datasets)
monkeypatch.setattr(AgentComposerService, "_create_config_version", fail_create_config_version)
with pytest.raises(AgentModelNotConfiguredError) as exc_info:
AgentComposerService.publish_agent_app_draft(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
version_note="ship it",
)
assert exc_info.value.error_code == "agent_model_not_configured"
assert agent.active_config_snapshot_id == "version-1"
assert agent.active_config_is_published is False
assert draft.base_snapshot_id == "version-1"
assert fake_session.commits == 0
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
agent = Agent(
id="agent-1",
@@ -4257,31 +4307,7 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch):
assert captured == {}
@pytest.mark.parametrize(
("variant", "save_call"),
[
(
ComposerVariant.AGENT_APP,
lambda payload: AgentComposerService.save_agent_app_composer(
tenant_id="tenant-1",
app_id="app-1",
account_id="account-1",
payload=payload,
),
),
(
ComposerVariant.WORKFLOW,
lambda payload: AgentComposerService.save_workflow_composer(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
payload=payload,
),
),
],
)
def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch, variant, save_call):
def test_validate_knowledge_datasets_rejects_malformed_ids_without_dataset_lookup(monkeypatch: pytest.MonkeyPatch):
captured = {"calls": 0}
def fake_get_datasets_by_ids(ids, tenant_id):
@@ -4294,60 +4320,29 @@ def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pyte
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
payload = ComposerSavePayload.model_validate(
agent_soul = AgentSoulConfig.model_validate(
{
"variant": variant.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"soul_lock": {"locked": False},
"agent_soul": {
"knowledge": {
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "not-a-uuid"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
}
"knowledge": {
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": "not-a-uuid"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
}
)
with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"):
save_call(payload)
AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul)
assert captured == {"calls": 0}
@pytest.mark.parametrize(
("variant", "save_call"),
[
(
ComposerVariant.AGENT_APP,
lambda payload: AgentComposerService.save_agent_app_composer(
tenant_id="tenant-1",
app_id="app-1",
account_id="account-1",
payload=payload,
),
),
(
ComposerVariant.WORKFLOW,
lambda payload: AgentComposerService.save_workflow_composer(
tenant_id="tenant-1",
app_id="app-1",
node_id="node-1",
account_id="account-1",
payload=payload,
),
),
],
)
def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
monkeypatch: pytest.MonkeyPatch, variant, save_call
):
def test_validate_knowledge_datasets_rejects_missing_or_out_of_scope_datasets(monkeypatch: pytest.MonkeyPatch):
captured = {}
missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000"
@@ -4360,20 +4355,70 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
agent_soul = AgentSoulConfig.model_validate(
{
"knowledge": {
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": missing_dataset_id}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
}
]
},
}
)
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul)
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
def test_save_agent_composer_allows_incomplete_knowledge_draft(monkeypatch: pytest.MonkeyPatch):
agent = SimpleNamespace(
id="agent-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="version-1",
active_config_is_published=True,
updated_by=None,
)
active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"))
fake_session = FakeSession(scalar=[agent])
saved = {}
import services.dataset_service as dataset_service_module
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(
dataset_service_module.DatasetService,
"get_datasets_by_ids",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("draft save must skip dataset lookup")),
)
monkeypatch.setattr(
AgentComposerService,
"_save_agent_draft",
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
payload = ComposerSavePayload.model_validate(
{
"variant": variant.value,
"variant": ComposerVariant.AGENT_APP.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"soul_lock": {"locked": False},
"agent_soul": {
"knowledge": {
"sets": [
{
"id": "support",
"name": "Support KB",
"datasets": [{"id": missing_dataset_id}],
"datasets": [{"id": "not-a-uuid"}],
"query": {"mode": "generated_query"},
"retrieval": {"mode": "multiple", "top_k": 4},
"retrieval": {"mode": "single"},
"metadata_filtering": {"mode": "automatic"},
}
]
}
@@ -4381,10 +4426,20 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
}
)
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
save_call(payload)
result = AgentComposerService.save_agent_composer(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
payload=payload,
)
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
assert result["loaded"] is True
assert saved["draft_type"] == AgentConfigDraftType.DRAFT
assert saved["agent_soul"].knowledge.sets[0].retrieval.mode == "single"
assert saved["agent_soul"].knowledge.sets[0].retrieval.model is None
assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.mode == "automatic"
assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.metadata_model_config is None
assert fake_session.commits == 1
def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch):
@@ -0,0 +1,42 @@
from types import SimpleNamespace
from typing import cast
from unittest.mock import Mock
import pytest
from graphon.enums import BuiltinNodeTypes
from models import App
from services import app_dsl_service
from services.app_dsl_service import AppDslService
from services.errors.app import WorkflowAgentNodeDslExportUnsupportedError
def test_append_workflow_export_data_rejects_agent_v2_nodes(monkeypatch: pytest.MonkeyPatch) -> None:
workflow = SimpleNamespace(
to_dict=lambda *, include_secret: {
"graph": {
"nodes": [
{
"id": "agent-node",
"data": {
"type": BuiltinNodeTypes.AGENT,
"version": "2",
},
}
]
}
}
)
workflow_service = Mock()
workflow_service.get_draft_workflow.return_value = workflow
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
with pytest.raises(
WorkflowAgentNodeDslExportUnsupportedError,
match="Workflow DSL export does not support Agent nodes yet.",
):
AppDslService._append_workflow_export_data(
export_data={},
app_model=cast(App, SimpleNamespace(tenant_id="tenant-1")),
include_secret=False,
)
@@ -823,9 +823,7 @@ class TestSegmentServiceMutations:
# scalars() for child_node_ids
mock_db.session.scalars.return_value.all.return_value = ["child-1"]
SegmentService.delete_segments(
["segment-1", "segment-2", "foreign-segment"], document, dataset, mock_db.session
)
SegmentService.delete_segments(["segment-1", "segment-2"], document, dataset, mock_db.session)
assert document.word_count == 0
mock_db.session.add.assert_called_once_with(document)
@@ -836,13 +834,6 @@ class TestSegmentServiceMutations:
["segment-1", "segment-2"],
["child-1"],
)
delete_stmt = mock_db.session.execute.call_args_list[1].args[0]
delete_sql = str(delete_stmt.compile(compile_kwargs={"literal_binds": True}))
assert "document_segments.id IN ('segment-1', 'segment-2')" in delete_sql
assert "document_segments.dataset_id = 'dataset-1'" in delete_sql
assert "document_segments.document_id = 'doc-1'" in delete_sql
assert "document_segments.tenant_id = 'tenant-1'" in delete_sql
assert "foreign-segment" not in delete_sql
mock_db.session.commit.assert_called_once()
def test_update_segments_status_enables_only_segments_without_indexing_cache(self):
@@ -37,10 +37,7 @@ from pydantic_ai.exceptions import UnexpectedModelBehavior
from pydantic_ai.messages import (
AudioUrl,
BinaryContent,
BuiltinToolCallPart,
BuiltinToolReturnPart,
CachePoint,
CompactionPart,
DocumentUrl,
FilePart,
FinishReason,
@@ -357,10 +354,8 @@ def _map_model_response_to_prompt_message(
),
)
)
elif isinstance(part, BuiltinToolCallPart | BuiltinToolReturnPart | CompactionPart):
raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}")
else:
assert_never(part)
raise UnexpectedModelBehavior(f"Unsupported response part for daemon adapter: {type(part).__name__}")
content = _normalize_prompt_content(content_parts)
if content is None and not tool_calls:
@@ -487,10 +482,16 @@ def _map_binary_content_to_prompt_content(
def _normalize_prompt_content(
content: list[PromptMessageContentUnionTypes],
) -> str | list[PromptMessageContentUnionTypes] | None:
"""Collapse text-only daemon message content to the string form.
The daemon protocol supports content-part lists for multimodal messages, but
text-only history is safer as plain text because provider plugins commonly
JSON-encode text payloads without Graphon model encoders.
"""
if not content:
return None
if len(content) == 1 and isinstance(content[0], TextPromptMessageContent):
return content[0].data
if all(isinstance(item, TextPromptMessageContent) for item in content):
return "".join(item.data for item in content)
return content
@@ -242,6 +242,45 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.parts[0].part_kind, "text")
self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response")
async def test_request_collapses_text_only_assistant_history_parts_to_string_content(self) -> None:
messages = [
ModelRequest(parts=[UserPromptPart("initial request")]),
ModelResponse(
parts=[
ThinkingPart(content="plan"),
TextPart(content="answer"),
]
),
ModelRequest(parts=[UserPromptPart("follow up")]),
]
def handler(request: httpx.Request) -> httpx.Response:
payload = json.loads(request.content.decode("utf-8"))
prompt_messages = payload["data"]["prompt_messages"]
self.assertEqual([message["role"] for message in prompt_messages], ["user", "assistant", "user"])
self.assertEqual(prompt_messages[1]["content"], "<think>\nplan\n</think>answer")
return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7))
async with self.mock_daemon_stream(httpx.MockTransport(handler)):
adapter = DifyLLMAdapterModel(
"demo-model",
self.make_provider(),
model_provider="openai",
credentials={"api_key": "secret"},
)
response = await adapter.request(
messages,
model_settings=None,
model_request_parameters=ModelRequestParameters(),
)
self.assertEqual(response.model_name, "demo-model")
self.assertEqual(response.parts[0].part_kind, "text")
self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response")
async def test_request_omits_empty_assistant_history_when_response_has_no_content_or_tool_calls(self) -> None:
messages = [
ModelRequest(parts=[SystemPromptPart("request system"), UserPromptPart("hello")]),
+10
View File
@@ -1,5 +1,15 @@
@agent-v2 @authenticated @build
Feature: Agent v2 build draft
@core
Scenario: Build chat is blocked until a model is configured
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And the Agent v2 composer draft uses the normal E2E prompt
When I open the Agent v2 configure page
And I try to generate an Agent v2 Build draft without a model
Then Agent v2 Build chat should be blocked until a model is configured
And the Agent v2 Build draft should not be checked out
@external-model @agent-backend-runtime @stable-model
Scenario: Generating a Build draft leaves the normal Agent configuration unchanged
Given I am signed in as the default E2E admin
+1 -1
View File
@@ -4,7 +4,7 @@ Feature: Agent Builder preseeded environment
Scenario: Agent lifecycle permissions are available
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And the Agent v2 composer draft uses the normal E2E prompt
And the Agent v2 composer draft is publishable
When I open the Agent v2 configure page
And I publish the Agent v2 draft
Then the Agent v2 draft should be published and up to date
+10
View File
@@ -1,5 +1,15 @@
@agent-v2 @authenticated @publish
Feature: Agent v2 publish
@core
Scenario: Publish is blocked until a model is configured
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And the Agent v2 composer draft uses the normal E2E prompt
When I open the Agent v2 configure page
And I try to publish the Agent v2 draft without a model
Then Agent v2 publish should be blocked until a model is configured
And the Agent v2 draft should remain unpublished
@core @stable-model
Scenario: Publish a configured Agent v2 draft
Given I am signed in as the default E2E admin
@@ -39,6 +39,21 @@ export async function saveAgentBuildDraft(
}
}
export async function agentBuildDraftExists(agentId: string): Promise<boolean> {
const ctx = await createApiContext()
try {
const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`)
if (response.status() === 404)
return false
await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`)
return true
}
finally {
await ctx.dispose()
}
}
export async function applyAgentBuildDraft(agentId: string): Promise<void> {
const ctx = await createApiContext()
try {
@@ -36,6 +36,11 @@ export const normalAgentSoulConfig: AgentSoulConfig = {
},
}
export const publishOnlyAgentModel: AgentModelSelection = {
name: 'gpt-5-nano',
provider: 'openai',
}
export const updatedAgentSoulConfig: AgentSoulConfig = {
prompt: {
system_prompt: updatedAgentPrompt,
@@ -81,6 +86,13 @@ export function createAgentSoulConfigWithModel(
}
}
export function createPublishableAgentSoulConfig(agentSoul: AgentSoulConfig): AgentSoulConfig {
if (agentSoul.model)
return agentSoul
return createAgentSoulConfigWithModel(agentSoul, publishOnlyAgentModel)
}
export function createAgentSoulConfigWithKnowledgeDataset(
agentSoul: AgentSoulConfig,
dataset: AgentKnowledgeDatasetConfig,
+15 -1
View File
@@ -8,7 +8,7 @@ import type {
} from '@dify/contracts/api/console/agent/types.gen'
import { createApiContext, expectApiResponseOK } from '../../../support/api'
import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming'
import { defaultAgentSoulConfig, normalAgentSoulConfig } from './agent-soul'
import { createPublishableAgentSoulConfig, defaultAgentSoulConfig, normalAgentSoulConfig } from './agent-soul'
export type AgentSeed = Pick<
AgentAppDetailWithSite,
@@ -156,6 +156,12 @@ export async function getAgentComposerDraft(agentId: string): Promise<AgentAppCo
}
}
export async function ensureAgentComposerDraftIsPublishable(agentId: string): Promise<void> {
const composer = await getAgentComposerDraft(agentId)
if (!composer.agent_soul?.model)
await saveAgentComposerDraft(agentId, createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig))
}
export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise<void> {
const ctx = await createApiContext()
try {
@@ -168,3 +174,11 @@ export async function publishAgent(agentId: string, versionNote = 'E2E publish')
await ctx.dispose()
}
}
export async function publishAgentWithPublishableDraft(
agentId: string,
versionNote = 'E2E publish',
): Promise<void> {
await ensureAgentComposerDraftIsPublishable(agentId)
await publishAgent(agentId, versionNote)
}
@@ -6,7 +6,7 @@ import {
setAgentApiAccess,
setAgentSiteAccessAndGetURL,
} from '../../agent-v2/support/access-point'
import { getAgentAccessPath, publishAgent } from '../../agent-v2/support/agent'
import { getAgentAccessPath, publishAgentWithPublishableDraft } from '../../agent-v2/support/agent'
import {
getAccessRegion,
getAccessSurfaceCard,
@@ -15,7 +15,7 @@ import {
} from './access-point-helpers'
Given('the Agent v2 draft has been published via API', async function (this: DifyWorld) {
await publishAgent(getCurrentAgentId(this))
await publishAgentWithPublishableDraft(getCurrentAgentId(this))
})
Given(
@@ -8,6 +8,7 @@ import {
saveAgentComposerDraft,
} from '../../agent-v2/support/agent'
import {
agentBuildDraftExists,
applyAgentBuildDraft,
saveAgentBuildDraft,
} from '../../agent-v2/support/agent-build-draft'
@@ -25,6 +26,7 @@ import { hasToolEntry } from '../../agent-v2/support/preflight/tools'
import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials'
import { getPreseededToolContract } from '../../agent-v2/support/tools'
import {
expectAgentModelRequiredFeedback,
getAgentEnvVariableValue,
getCurrentAgentId,
uploadSummaryConfigSkillForBuildDraft,
@@ -142,6 +144,17 @@ When(
},
)
When(
'I try to generate an Agent v2 Build draft without a model',
async function (this: DifyWorld) {
const page = this.getPage()
await page.getByRole('button', { exact: true, name: 'Build' }).click()
await page.getByPlaceholder('Describe what your agent should do').fill('Update the agent instructions for E2E.')
await page.getByRole('button', { name: 'Start build' }).click()
},
)
const expectPageResponseOK = async (response: Response, action: string) => {
if (response.ok())
return
@@ -246,6 +259,17 @@ Then('I should see the Agent v2 Build mode confirmation state', async function (
).toBeVisible()
})
Then('Agent v2 Build chat should be blocked until a model is configured', async function (this: DifyWorld) {
await expectAgentModelRequiredFeedback(this.getPage())
})
Then('the Agent v2 Build draft should not be checked out', async function (this: DifyWorld) {
await expect.poll(
async () => agentBuildDraftExists(getCurrentAgentId(this)),
{ timeout: 30_000 },
).toBe(false)
})
Then('I should see the e2e-summary-skill Skill in the Skills section', async function (this: DifyWorld) {
const skillsSection = this.getPage().getByRole('region', { name: 'Skills' })
@@ -138,6 +138,10 @@ export const expectAgentConfigFileSaved = async (
})
}
export const expectAgentModelRequiredFeedback = async (page: ReturnType<DifyWorld['getPage']>) => {
await expect(page.getByText('Select your model')).toBeVisible({ timeout: 10_000 })
}
export const uploadSummaryConfigSkillForBuildDraft = async (world: DifyWorld) => {
const agentId = getCurrentAgentId(world)
const skill = await uploadAgentConfigSkillToDraft({
@@ -15,6 +15,7 @@ import {
concurrentFirstAgentPrompt,
concurrentSecondAgentPrompt,
createAgentSoulConfigWithModel,
createPublishableAgentSoulConfig,
normalAgentPrompt,
normalAgentSoulConfig,
updatedAgentPrompt,
@@ -131,6 +132,16 @@ Given('the Agent v2 composer draft uses the normal E2E prompt', async function (
await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig)
})
Given(
'the Agent v2 composer draft is publishable',
async function (this: DifyWorld) {
await saveAgentComposerDraft(
getCurrentAgentId(this),
createPublishableAgentSoulConfig(normalAgentSoulConfig),
)
},
)
Given('the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) {
const agentId = getCurrentAgentId(this)
const upload = await uploadAgentDriveSkill({
@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure'
import { getAgentVersionDetail, getTestAgent } from '../../agent-v2/support/agent'
import { normalAgentPrompt } from '../../agent-v2/support/agent-soul'
import { getCurrentAgentId } from './configure-helpers'
import { expectAgentModelRequiredFeedback, getCurrentAgentId } from './configure-helpers'
When('I publish the Agent v2 draft', async function (this: DifyWorld) {
const page = this.getPage()
@@ -14,6 +14,25 @@ When('I publish the Agent v2 draft', async function (this: DifyWorld) {
await publishButton.click()
})
When('I try to publish the Agent v2 draft without a model', async function (this: DifyWorld) {
const page = this.getPage()
const publishButton = page.getByRole('button', { name: /^Publish(?: update)?$/ })
await expect(publishButton).toBeEnabled({ timeout: 30_000 })
await publishButton.click()
})
Then('Agent v2 publish should be blocked until a model is configured', async function (this: DifyWorld) {
await expectAgentModelRequiredFeedback(this.getPage())
})
Then('the Agent v2 draft should remain unpublished', async function (this: DifyWorld) {
await expect.poll(
async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published,
{ timeout: 30_000 },
).toBe(false)
})
Then('the Agent v2 configuration should be saved automatically', async function (this: DifyWorld) {
await waitForAgentConfigureAutosaved(this.getPage())
})
@@ -1116,6 +1116,7 @@ export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'work
export type AgentStatus = 'active' | 'archived'
export type AgentSoulAppFeaturesConfig = {
file_upload?: AgentFileUploadFeatureConfig
opening_statement?: string | null
retriever_resource?: AgentFeatureToggleConfig | null
sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null
@@ -1428,6 +1429,16 @@ export type AgentConfigRevisionOperation
| 'save_new_version'
| 'save_to_roster'
export type AgentFileUploadFeatureConfig = {
allowed_file_extensions?: Array<string>
allowed_file_types?: Array<FileType>
allowed_file_upload_methods?: Array<FileTransferMethod>
enabled?: boolean
image?: AgentFileUploadImageFeatureConfig
number_limits?: number
[key: string]: unknown
}
export type AgentSecretRefConfig = {
credential_id?: string | null
env_name?: string | null
@@ -1679,6 +1690,15 @@ export type FormInputConfig
export type JsonValue2 = unknown
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
export type AgentFileUploadImageFeatureConfig = {
enabled?: boolean
[key: string]: unknown
}
export type AgentKnowledgeDatasetConfig = {
description?: string | null
id?: string | null
@@ -1801,10 +1821,6 @@ export type StringListSource = {
value?: Array<string>
}
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
export type AgentKnowledgeMetadataCondition = {
comparison_operator:
| '<'
@@ -1926,19 +1926,6 @@ export const zAgentAppFeaturesPayload = z.object({
text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(),
})
/**
* AgentSoulAppFeaturesConfig
*/
export const zAgentSoulAppFeaturesConfig = z.object({
opening_statement: z.string().nullish(),
retriever_resource: zAgentFeatureToggleConfig.nullish(),
sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(),
speech_to_text: zAgentFeatureToggleConfig.nullish(),
suggested_questions: z.array(z.string()).nullish(),
suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(),
text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(),
})
export const zJsonValue2 = z.unknown()
/**
@@ -1953,6 +1940,54 @@ export const zHumanInputFormSubmissionData = z.object({
submitted_data: z.record(z.string(), zJsonValue2).nullish(),
})
/**
* FileType
*/
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
/**
* FileTransferMethod
*/
export const zFileTransferMethod = z.enum([
'datasource_file',
'local_file',
'remote_url',
'tool_file',
])
/**
* AgentFileUploadImageFeatureConfig
*/
export const zAgentFileUploadImageFeatureConfig = z.object({
enabled: z.boolean().optional().default(true),
})
/**
* AgentFileUploadFeatureConfig
*/
export const zAgentFileUploadFeatureConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
enabled: z.boolean().optional().default(true),
image: zAgentFileUploadImageFeatureConfig.optional(),
number_limits: z.int().optional().default(3),
})
/**
* AgentSoulAppFeaturesConfig
*/
export const zAgentSoulAppFeaturesConfig = z.object({
file_upload: zAgentFileUploadFeatureConfig.optional(),
opening_statement: z.string().nullish(),
retriever_resource: zAgentFeatureToggleConfig.nullish(),
sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(),
speech_to_text: zAgentFeatureToggleConfig.nullish(),
suggested_questions: z.array(z.string()).nullish(),
suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(),
text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(),
})
/**
* AgentKnowledgeDatasetConfig
*/
@@ -2182,6 +2217,29 @@ export const zUserActionConfig = z.object({
title: z.string().max(100),
})
/**
* FileInputConfig
*/
export const zFileInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
output_variable_name: z.string(),
type: z.literal('file').optional().default('file'),
})
/**
* FileListInputConfig
*/
export const zFileListInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
number_limits: z.int().gte(0).optional().default(0),
output_variable_name: z.string(),
type: z.literal('file-list').optional().default('file-list'),
})
/**
* AgentKnowledgeModelConfig
*/
@@ -2204,8 +2262,9 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']
*
* Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
* legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
* set owns its own query policy, so ``user_query`` must carry an explicit
* ``value`` while ``generated_query`` leaves that value empty.
* set owns its own query policy. Mode-dependent completeness, such as
* requiring ``value`` for ``user_query``, is enforced by composer publish
* validation so draft saves can persist partially configured knowledge sets.
*/
export const zAgentKnowledgeQueryConfig = z.object({
mode: zAgentKnowledgeQueryMode,
@@ -2235,8 +2294,9 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({
* Per-set retrieval policy for Agent v2 knowledge retrieval.
*
* Retrieval settings now live on each knowledge set instead of one shared
* flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
* ``single`` retrieval with a required model config.
* flat config. Mode-dependent completeness, such as requiring ``top_k`` for
* ``multiple`` or a model for ``single``, is enforced by composer publish
* validation so draft saves can persist partially configured knowledge sets.
*/
export const zAgentKnowledgeRetrievalConfig = z.object({
mode: z.enum(['multiple', 'single']),
@@ -2249,44 +2309,6 @@ export const zAgentKnowledgeRetrievalConfig = z.object({
weights: zAgentKnowledgeWeightedScoreConfig.nullish(),
})
/**
* FileType
*/
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
/**
* FileTransferMethod
*/
export const zFileTransferMethod = z.enum([
'datasource_file',
'local_file',
'remote_url',
'tool_file',
])
/**
* FileInputConfig
*/
export const zFileInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
output_variable_name: z.string(),
type: z.literal('file').optional().default('file'),
})
/**
* FileListInputConfig
*/
export const zFileListInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
number_limits: z.int().gte(0).optional().default(0),
output_variable_name: z.string(),
type: z.literal('file-list').optional().default('file-list'),
})
/**
* AgentKnowledgeMetadataCondition
*/
@@ -2331,6 +2353,8 @@ export const zAgentKnowledgeMetadataConditions = z.object({
* The Python attribute uses ``metadata_model_config`` for clarity because the
* model belongs to metadata filtering specifically, while the external API and
* generated schema keep the historical ``model_config`` field name via alias.
* Mode-dependent completeness is enforced by composer publish validation so
* draft saves can persist partially configured metadata filters.
*/
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
conditions: zAgentKnowledgeMetadataConditions.nullish(),
@@ -2368,6 +2368,7 @@ export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'work
export type AgentStatus = 'active' | 'archived'
export type AgentSoulAppFeaturesConfig = {
file_upload?: AgentFileUploadFeatureConfig
opening_statement?: string | null
retriever_resource?: AgentFeatureToggleConfig | null
sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null
@@ -2627,6 +2628,16 @@ export type WorkflowFileUploadPreviewConfigPayload = {
mode?: string | null
}
export type AgentFileUploadFeatureConfig = {
allowed_file_extensions?: Array<string>
allowed_file_types?: Array<FileType>
allowed_file_upload_methods?: Array<FileTransferMethod>
enabled?: boolean
image?: AgentFileUploadImageFeatureConfig
number_limits?: number
[key: string]: unknown
}
export type AgentFeatureToggleConfig = {
enabled?: boolean
[key: string]: unknown
@@ -2873,6 +2884,15 @@ export type FileListInputConfig = {
type?: 'file-list'
}
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
export type AgentFileUploadImageFeatureConfig = {
enabled?: boolean
[key: string]: unknown
}
export type AgentModerationProviderConfig = {
api_based_extension_id?: string | null
inputs_config?: AgentModerationIoConfig | null
@@ -2942,10 +2962,6 @@ export type StringListSource = {
value?: Array<string>
}
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
export type AgentModerationIoConfig = {
enabled?: boolean
preset_response?: string | null
@@ -3466,6 +3466,63 @@ export const zUserActionConfig = z.object({
title: z.string().max(100),
})
/**
* FileType
*/
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
/**
* FileTransferMethod
*/
export const zFileTransferMethod = z.enum([
'datasource_file',
'local_file',
'remote_url',
'tool_file',
])
/**
* FileInputConfig
*/
export const zFileInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
output_variable_name: z.string(),
type: z.literal('file').optional().default('file'),
})
/**
* FileListInputConfig
*/
export const zFileListInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
number_limits: z.int().gte(0).optional().default(0),
output_variable_name: z.string(),
type: z.literal('file-list').optional().default('file-list'),
})
/**
* AgentFileUploadImageFeatureConfig
*/
export const zAgentFileUploadImageFeatureConfig = z.object({
enabled: z.boolean().optional().default(true),
})
/**
* AgentFileUploadFeatureConfig
*/
export const zAgentFileUploadFeatureConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
enabled: z.boolean().optional().default(true),
image: zAgentFileUploadImageFeatureConfig.optional(),
number_limits: z.int().optional().default(3),
})
/**
* AgentSuggestedQuestionsAfterAnswerModelConfig
*
@@ -3662,44 +3719,6 @@ export const zAgentSoulToolsConfig = z.object({
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
})
/**
* FileType
*/
export const zFileType = z.enum(['audio', 'custom', 'document', 'image', 'video'])
/**
* FileTransferMethod
*/
export const zFileTransferMethod = z.enum([
'datasource_file',
'local_file',
'remote_url',
'tool_file',
])
/**
* FileInputConfig
*/
export const zFileInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
output_variable_name: z.string(),
type: z.literal('file').optional().default('file'),
})
/**
* FileListInputConfig
*/
export const zFileListInputConfig = z.object({
allowed_file_extensions: z.array(z.string()).optional(),
allowed_file_types: z.array(zFileType).optional(),
allowed_file_upload_methods: z.array(zFileTransferMethod).optional(),
number_limits: z.int().gte(0).optional().default(0),
output_variable_name: z.string(),
type: z.literal('file-list').optional().default('file-list'),
})
/**
* AgentModerationIOConfig
*/
@@ -3731,6 +3750,7 @@ export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({
* AgentSoulAppFeaturesConfig
*/
export const zAgentSoulAppFeaturesConfig = z.object({
file_upload: zAgentFileUploadFeatureConfig.optional(),
opening_statement: z.string().nullish(),
retriever_resource: zAgentFeatureToggleConfig.nullish(),
sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(),
@@ -3762,8 +3782,9 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query']
*
* Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
* legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
* set owns its own query policy, so ``user_query`` must carry an explicit
* ``value`` while ``generated_query`` leaves that value empty.
* set owns its own query policy. Mode-dependent completeness, such as
* requiring ``value`` for ``user_query``, is enforced by composer publish
* validation so draft saves can persist partially configured knowledge sets.
*/
export const zAgentKnowledgeQueryConfig = z.object({
mode: zAgentKnowledgeQueryMode,
@@ -3793,8 +3814,9 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({
* Per-set retrieval policy for Agent v2 knowledge retrieval.
*
* Retrieval settings now live on each knowledge set instead of one shared
* flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
* ``single`` retrieval with a required model config.
* flat config. Mode-dependent completeness, such as requiring ``top_k`` for
* ``multiple`` or a model for ``single``, is enforced by composer publish
* validation so draft saves can persist partially configured knowledge sets.
*/
export const zAgentKnowledgeRetrievalConfig = z.object({
mode: z.enum(['multiple', 'single']),
@@ -3972,6 +3994,8 @@ export const zAgentKnowledgeMetadataConditions = z.object({
* The Python attribute uses ``metadata_model_config`` for clarity because the
* model belongs to metadata filtering specifically, while the external API and
* generated schema keep the historical ``model_config`` field name via alias.
* Mode-dependent completeness is enforced by composer publish validation so
* draft saves can persist partially configured metadata filters.
*/
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
conditions: zAgentKnowledgeMetadataConditions.nullish(),
@@ -8,7 +8,7 @@ import {
FieldRoot,
} from '../field'
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
import { Radio, RadioControl, RadioRoot } from '../radio'
import { Radio, RadioControl, RadioIndicator, RadioRoot } from '../radio'
const meta = {
title: 'Base/Form/RadioGroup',
@@ -17,7 +17,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'RadioGroup primitive built on Base UI. For normal form rows, compose FieldRoot, FieldsetRoot, FieldLabel, RadioGroup, and Radio. For option cards, wrap each option in FieldItem and make the card itself a RadioRoot with variant="unstyled".',
component: '`RadioGroup` owns single-selection state. Use `Radio` for plain form rows, `RadioRoot` when an entire row or card is the radio item, `RadioControl` for the standard visual dot inside custom roots, and `RadioIndicator` only when the design owns a custom control shell.',
},
},
},
@@ -60,7 +60,7 @@ export const StandardFormRows: Story = {
parameters: {
docs: {
description: {
story: 'Default form composition. Most product code should use this shape: RadioGroup owns value, FieldsetLegend names the group, and FieldLabel makes each row clickable.',
story: 'Plain form-row composition. `RadioGroup` owns value, `FieldsetLegend` names the group, `FieldLabel` makes each option label clickable, and `Radio` renders the default dot.',
},
},
},
@@ -107,31 +107,39 @@ export const BooleanInline: Story = {
},
}
type PromptMode = 'default' | 'custom'
function OptionCardsDemo() {
const [value, setValue] = React.useState('default')
const [value, setValue] = React.useState<PromptMode>('default')
const options = [
{
value: 'default',
title: 'Default prompt',
description: 'Use the built-in prompt for consistent output.',
},
{
value: 'custom',
title: 'Custom prompt',
description: 'Write a prompt for this app and keep full control.',
},
] satisfies Array<{
value: PromptMode
title: string
description: string
}>
return (
<FieldRoot name="promptMode" className="w-100">
<FieldsetRoot
render={(
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-stretch gap-3" />
<RadioGroup<PromptMode> value={value} onValueChange={setValue} className="flex-col items-stretch gap-3" />
)}
>
<FieldsetLegend>Prompt mode</FieldsetLegend>
{[
{
value: 'default',
title: 'Default prompt',
description: 'Use the built-in prompt for consistent output.',
},
{
value: 'custom',
title: 'Custom prompt',
description: 'Write a prompt for this app and keep full control.',
},
].map(option => (
{options.map(option => (
<FieldItem key={option.value}>
<RadioRoot
<RadioRoot<PromptMode>
value={option.value}
variant="unstyled"
nativeButton
@@ -162,7 +170,76 @@ export const OptionCards: Story = {
parameters: {
docs: {
description: {
story: 'Wrap each option card in FieldItem, then use RadioRoot with variant="unstyled" when the entire card is the radio. RadioControl renders the visual dot inside the card.',
story: 'Product option cards should make the whole card the radio item with `RadioRoot variant="unstyled"`. `RadioControl` renders the standard visual dot inside the custom root.',
},
},
},
}
type ApprovalMode = 'automatic' | 'manual'
function CustomIndicatorPartDemo() {
const [value, setValue] = React.useState<ApprovalMode>('automatic')
const options = [
{
value: 'automatic',
title: 'Automatic approval',
description: 'Approve requests that match policy.',
},
{
value: 'manual',
title: 'Manual review',
description: 'Ask an admin to review each request.',
},
] satisfies Array<{
value: ApprovalMode
title: string
description: string
}>
return (
<FieldRoot name="approvalMode" className="w-100">
<FieldsetRoot
render={(
<RadioGroup<ApprovalMode> value={value} onValueChange={setValue} className="flex-col items-stretch gap-2" />
)}
>
<FieldsetLegend>Approval mode</FieldsetLegend>
{options.map(option => (
<FieldItem key={option.value}>
<RadioRoot<ApprovalMode>
value={option.value}
variant="unstyled"
nativeButton
render={<button type="button" />}
className="flex w-full items-center gap-3 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg px-3 py-2 text-left outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg"
>
<span className="flex size-4 shrink-0 items-center justify-center rounded-full border border-components-radio-border bg-components-radio-bg">
<RadioIndicator className="text-components-radio-border-checked" />
</span>
<span className="min-w-0 grow">
<span className="block truncate system-sm-semibold text-text-primary">
{option.title}
</span>
<span className="block truncate system-xs-regular text-text-tertiary">
{option.description}
</span>
</span>
</RadioRoot>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
)
}
export const CustomIndicatorPart: Story = {
render: () => <CustomIndicatorPartDemo />,
parameters: {
docs: {
description: {
story: '`RadioIndicator` is the low-level indicator part. Use it only when a custom root owns the outer control shell; otherwise prefer `Radio` for form rows or `RadioControl` inside option cards.',
},
},
},
+8 -1
View File
@@ -15,7 +15,7 @@ const meta = {
layout: 'centered',
docs: {
description: {
component: 'Radio primitive built on Base UI. It preserves RadioGroup selection, hidden input, disabled, and form semantics while applying the Dify 16px radio design from Figma. Import from `@langgenius/dify-ui/radio` and place radios inside `RadioGroup` from `@langgenius/dify-ui/radio-group`.',
component: '`Radio` is the default Dify 16px radio control, intended for plain form rows inside `RadioGroup`. It does not accept children. For option cards or rich rows, use `RadioRoot` as the item root and place `RadioControl` inside it; see the `RadioGroup` stories.',
},
},
},
@@ -68,6 +68,13 @@ export const Default: Story = {
args: {
disabled: false,
},
parameters: {
docs: {
description: {
story: '`Radio` renders the standard visual control. `FieldLabel` owns the clickable text label, and `RadioGroup` owns the selected value.',
},
},
},
}
export const Disabled: Story = {
+2 -8
View File
@@ -80,17 +80,11 @@ export function RadioControl({
export type RadioProps<Value = string>
= Omit<RadioRootProps<Value>, 'children'>
export function Radio<Value = string>({
...props
}: RadioProps<Value>) {
export function Radio<Value = string>(props: RadioProps<Value>) {
return <RadioRoot<Value> {...props} />
}
export type RadioSkeletonProps
= Omit<React.ComponentProps<'div'>, 'className'>
& {
className?: string
}
export type RadioSkeletonProps = React.ComponentProps<'div'>
export function RadioSkeleton({
className,
@@ -211,16 +211,18 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
{shouldShowFooterNotice && (
<div className="m-1 mt-0 -translate-y-2 rounded-b-[10px] border-r border-b border-l border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 px-2.5 py-2 pt-4">
<div className="flex items-center gap-1">
<span aria-hidden className="i-ri-information-line size-3.5 shrink-0 text-text-accent" />
<div className="body-xs-medium text-text-accent">{footerNotice}</div>
{shouldShowFooterNoticeTooltip && (
<Tooltip>
<TooltipTrigger
render={(
<button
type="button"
className="flex size-5 items-center justify-center rounded-md text-text-accent hover:bg-state-base-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg"
className="ml-auto flex size-5 items-center justify-center rounded-md system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg"
aria-label={typeof footerNoticeTooltip === 'string' ? footerNoticeTooltip : undefined}
>
<span aria-hidden className="i-ri-information-line size-3.5 shrink-0" />
<span aria-hidden className="i-ri-question-line size-3.5 shrink-0" />
</button>
)}
/>
@@ -229,7 +231,6 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
</TooltipContent>
</Tooltip>
)}
<div className="body-xs-medium text-text-accent">{footerNotice}</div>
</div>
</div>
)}
@@ -14,7 +14,7 @@ export function SnippetPlaceholderIcon({
return (
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-divider-subtle bg-state-accent-hover text-text-accent shadow-xs',
'flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-divider-subtle bg-state-accent-hover text-[#5D6FBB] shadow-xs',
className,
)}
aria-hidden="true"
@@ -1,3 +1,7 @@
import type {
AgentInviteOptionResponse,
AgentInviteOptionsResponse,
} from '@dify/contracts/api/console/agent/types.gen'
import type { NodeDefault } from '../../types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
@@ -15,7 +19,7 @@ const runtimeState = vi.hoisted(() => ({
}))
const queryMocks = vi.hoisted(() => ({
inviteOptionsQueryFn: vi.fn(),
request: vi.fn(),
toastError: vi.fn(),
}))
@@ -35,19 +39,8 @@ vi.mock('@/app/components/app/store', () => ({
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
inviteOptions: {
get: {
queryOptions: (options: unknown) => ({
queryKey: ['agents', 'invite-options', options],
queryFn: () => queryMocks.inviteOptionsQueryFn(options),
}),
},
},
},
},
vi.mock('@/service/base', () => ({
request: (...args: unknown[]) => queryMocks.request(...args),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@@ -74,6 +67,58 @@ const createBlock = (
checkValid: () => ({ isValid: true }),
})
const createInviteOption = (
overrides: Partial<AgentInviteOptionResponse> & Pick<AgentInviteOptionResponse, 'id' | 'name'>,
): AgentInviteOptionResponse => {
const { id, name, ...rest } = overrides
return {
id,
name,
description: rest.description ?? 'Clarification Drafter',
active_config_snapshot_id: rest.active_config_snapshot_id ?? 'version-1',
role: rest.role ?? 'Researcher',
agent_kind: rest.agent_kind ?? 'dify_agent',
icon: rest.icon ?? 'A',
icon_background: rest.icon_background ?? '#E9D7FE',
icon_type: rest.icon_type ?? 'emoji',
scope: rest.scope ?? 'roster',
source: rest.source ?? 'workflow',
status: rest.status ?? 'active',
...rest,
}
}
const createInviteOptionsResponse = (
agents: AgentInviteOptionResponse[],
): AgentInviteOptionsResponse => ({
data: agents,
has_more: false,
limit: 8,
page: 1,
total: agents.length,
})
const createJsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})
const mockInviteOptionsResponse = (agents: AgentInviteOptionResponse[]) => {
queryMocks.request.mockImplementation(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse(agents))))
}
const expectLastInviteOptionsRequest = () => {
const [url] = queryMocks.request.mock.calls.at(-1) ?? []
const requestURL = new URL(String(url), window.location.origin)
expect(requestURL.pathname).toBe('/console/api/agent/invite-options')
return requestURL
}
describe('Blocks', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -125,13 +170,7 @@ describe('Blocks', () => {
it('opens the agent selector on Agent block hover', async () => {
const user = userEvent.setup()
queryMocks.inviteOptionsQueryFn.mockResolvedValue({
data: [],
has_more: false,
limit: 8,
page: 1,
total: 0,
})
mockInviteOptionsResponse([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -171,28 +210,12 @@ describe('Blocks', () => {
it('opens the agent selector from the Agent block and selects an agent', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
queryMocks.inviteOptionsQueryFn.mockResolvedValue({
data: [
{
id: 'agent-1',
name: 'Nadia',
description: 'Clarification Drafter',
active_config_snapshot_id: 'version-1',
role: 'Researcher',
agent_kind: 'dify_agent',
icon: 'A',
icon_background: '#E9D7FE',
icon_type: 'emoji',
scope: 'roster',
source: 'workflow',
status: 'active',
},
],
has_more: false,
limit: 8,
page: 1,
total: 1,
})
mockInviteOptionsResponse([
createInviteOption({
id: 'agent-1',
name: 'Nadia',
}),
])
const queryClient = new QueryClient({
defaultOptions: {
@@ -246,42 +269,84 @@ describe('Blocks', () => {
agent_node_kind: 'dify_agent',
version: '2',
})
expect(queryMocks.inviteOptionsQueryFn).toHaveBeenCalledWith({
input: {
query: {
app_id: 'app-1',
limit: 8,
page: 1,
const requestURL = expectLastInviteOptionsRequest()
expect(requestURL.searchParams.get('app_id')).toBe('app-1')
expect(requestURL.searchParams.get('limit')).toBe('8')
expect(requestURL.searchParams.get('page')).toBe('1')
})
it('should refresh Agent v2 roster options when the selector is reopened', async () => {
const user = userEvent.setup()
queryMocks.request
.mockImplementationOnce(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse([
createInviteOption({
id: 'agent-1',
name: 'Nadia',
}),
]))))
.mockImplementation(() => Promise.resolve(createJsonResponse(createInviteOptionsResponse([
createInviteOption({
id: 'agent-2',
name: 'Bruno',
role: 'Planner',
}),
]))))
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 5 * 60 * 1000,
},
},
})
const hooksStore = createHooksStore({
configsMap: {
flowId: 'app-1',
flowType: FlowType.appFlow,
fileSettings: {} as never,
},
})
render(
<QueryClientProvider client={queryClient}>
<HooksStoreContext value={hooksStore}>
<Blocks
searchText=""
onSelect={vi.fn()}
availableBlocksTypes={[BlockEnum.AgentV2]}
blocks={[createBlock(BlockEnum.AgentV2, 'Agent', BlockClassificationEnum.Default, 3)]}
/>
</HooksStoreContext>
</QueryClientProvider>,
)
await user.click(screen.getByRole('button', { name: /Agent/ }))
expect(await screen.findByText('Nadia')).toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: 'agentV2.roster.searchLabel' }))
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: 'agentV2.roster.nodeSelector.dialogLabel' })).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /Agent/ }))
expect(await screen.findByText('Bruno')).toBeInTheDocument()
expect(screen.getByText('Planner')).toBeInTheDocument()
await waitFor(() => expect(queryMocks.request).toHaveBeenCalledTimes(2))
expect(screen.queryByText('Nadia')).not.toBeInTheDocument()
})
it('does not select an Agent v2 roster agent without active config snapshot', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
queryMocks.inviteOptionsQueryFn.mockResolvedValue({
data: [
{
id: 'agent-1',
name: 'Nadia',
description: 'Clarification Drafter',
active_config_snapshot_id: null,
role: 'Researcher',
agent_kind: 'dify_agent',
icon: 'A',
icon_background: '#E9D7FE',
icon_type: 'emoji',
scope: 'roster',
source: 'workflow',
status: 'active',
},
],
has_more: false,
limit: 8,
page: 1,
total: 1,
})
mockInviteOptionsResponse([
createInviteOption({
id: 'agent-1',
name: 'Nadia',
active_config_snapshot_id: null,
}),
])
const queryClient = new QueryClient({
defaultOptions: {
@@ -323,13 +388,7 @@ describe('Blocks', () => {
it('inserts an inline Agent v2 node from the selector start action', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
queryMocks.inviteOptionsQueryFn.mockResolvedValue({
data: [],
has_more: false,
limit: 8,
page: 1,
total: 0,
})
mockInviteOptionsResponse([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -376,13 +435,7 @@ describe('Blocks', () => {
it('closes the agent selector when Escape closes the combobox', async () => {
const user = userEvent.setup()
queryMocks.inviteOptionsQueryFn.mockResolvedValue({
data: [],
has_more: false,
limit: 8,
page: 1,
total: 0,
})
mockInviteOptionsResponse([])
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -66,6 +66,7 @@ export function AgentSelectorContent({
},
},
}),
staleTime: 0,
})
const agents = agentsQuery.data?.data ?? []
const actionOptions: AgentSelectorActionOption[] = onStartFromScratch
@@ -101,6 +101,10 @@ describe('agent/default', () => {
})
})
it('reuses the legacy agent node help document', () => {
expect(nodeDefault.metaData.helpLinkUri).toBe('agent')
})
it('identifies version 2 agent data as Agent v2', () => {
expect(isAgentV2NodeData(createPayload({ type: BlockEnum.Agent }))).toBe(true)
expect(isAgentV2NodeData({
@@ -7,6 +7,7 @@ import { hasValidAgentBinding } from './types'
const metaData = genNodeMetaData({
sort: 3,
type: BlockEnum.AgentV2,
helpLinkUri: 'agent',
})
const nodeDefault: NodeDefault<AgentV2NodeType> = {
@@ -1,18 +1,33 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { AgentDetailLayout } from '../layout'
const mockReplace = vi.hoisted(() => vi.fn())
const mockAgentQuery = vi.hoisted(() => ({
data: {
name: 'Agent',
} as { name: string } | undefined,
error: null as unknown,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: vi.fn(() => ({
data: {
name: 'Agent',
},
})),
useQuery: vi.fn(() => mockAgentQuery),
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
push: vi.fn(),
replace: mockReplace,
prefetch: vi.fn(),
}),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
@@ -32,6 +47,10 @@ vi.mock('@/service/client', () => ({
describe('AgentDetailLayout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAgentQuery.data = {
name: 'Agent',
}
mockAgentQuery.error = null
})
it('should render detail content without owning navigation landmarks', () => {
@@ -45,4 +64,20 @@ describe('AgentDetailLayout', () => {
expect(screen.queryByRole('main')).not.toBeInTheDocument()
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
})
it('should redirect to roster when agent detail returns 404', async () => {
mockAgentQuery.data = undefined
mockAgentQuery.error = new Response(null, { status: 404 })
render(
<AgentDetailLayout agentId="missing-agent">
<div>Agent detail content</div>
</AgentDetailLayout>,
)
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/roster')
})
expect(screen.queryByText('Agent detail content')).not.toBeInTheDocument()
})
})
@@ -5,9 +5,10 @@ import { WorkflowReferencesTable } from '../workflow-references-table'
const mocks = vi.hoisted(() => ({
queryFn: vi.fn(),
queryOptions: vi.fn((input: unknown) => ({
queryOptions: vi.fn(({ enabled = true, input }: { enabled?: boolean, input: unknown }) => ({
queryKey: ['agent-referencing-workflows', input],
queryFn: () => mocks.queryFn(input),
enabled,
})),
}))
@@ -31,7 +32,7 @@ vi.mock('@/hooks/use-timestamp', () => ({
}),
}))
const renderTable = () => {
const renderTable = ({ enabled }: { enabled?: boolean } = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -42,7 +43,7 @@ const renderTable = () => {
render(
<QueryClientProvider client={queryClient}>
<WorkflowReferencesTable agentId="agent-1" />
<WorkflowReferencesTable agentId="agent-1" enabled={enabled} />
</QueryClientProvider>,
)
@@ -67,9 +68,27 @@ describe('WorkflowReferencesTable', () => {
agent_id: 'agent-1',
},
},
enabled: true,
})
})
})
it('should not fetch workflow references when disabled', async () => {
renderTable({ enabled: false })
await waitFor(() => {
expect(mocks.queryOptions).toHaveBeenCalledWith({
input: {
params: {
agent_id: 'agent-1',
},
},
enabled: false,
})
})
expect(mocks.queryFn).not.toHaveBeenCalled()
expect(screen.queryByText('agentV2.agentDetail.access.workflow.loading')).not.toBeInTheDocument()
})
})
describe('Rendering', () => {
@@ -12,6 +12,7 @@ import { consoleQuery } from '@/service/client'
type WorkflowReferencesTableProps = {
agentId: string
enabled?: boolean
}
const workflowTableColSpan = 5
@@ -20,6 +21,7 @@ const getWorkflowReferenceHref = (reference: AgentReferencingWorkflowResponse) =
export function WorkflowReferencesTable({
agentId,
enabled = true,
}: WorkflowReferencesTableProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
@@ -29,6 +31,7 @@ export function WorkflowReferencesTable({
agent_id: agentId,
},
},
enabled,
}))
const workflowReferences = workflowReferencesQuery.data?.data ?? []
@@ -62,12 +65,12 @@ export function WorkflowReferencesTable({
</tr>
</thead>
<tbody className="system-sm-regular text-text-secondary">
{workflowReferencesQuery.isPending && (
{enabled && workflowReferencesQuery.isPending && (
<WorkflowAccessStateRow>
{t('agentDetail.access.workflow.loading')}
</WorkflowAccessStateRow>
)}
{workflowReferencesQuery.isError && (
{enabled && workflowReferencesQuery.isError && (
<WorkflowAccessStateRow>
<div className="flex items-center justify-center gap-2">
<span>{t('agentDetail.access.workflow.loadFailed')}</span>
@@ -83,12 +86,12 @@ export function WorkflowReferencesTable({
</div>
</WorkflowAccessStateRow>
)}
{workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && (
{enabled && workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && (
<WorkflowAccessStateRow>
{t('agentDetail.access.workflow.empty')}
</WorkflowAccessStateRow>
)}
{workflowReferencesQuery.isSuccess && workflowReferences.map(reference => (
{enabled && workflowReferencesQuery.isSuccess && workflowReferences.map(reference => (
<WorkflowAccessRow
key={`${reference.app_id}:${reference.workflow_id}`}
reference={reference}
@@ -71,7 +71,7 @@ export function AgentAccessPage({
</p>
</div>
<WorkflowReferencesTable agentId={agentId} />
<WorkflowReferencesTable agentId={agentId} enabled={agentQuery.isSuccess} />
</section>
</div>
</ScrollArea>
@@ -54,6 +54,19 @@ const mocks = vi.hoisted(() => ({
},
}))
const toastMock = vi.hoisted(() => ({
error: vi.fn(),
}))
const modelHooksState = vi.hoisted(() => ({
defaultTextGenerationModel: {
provider: {
provider: 'langgenius/openai/openai',
},
model: 'gpt-4o-mini',
} as { provider: { provider: string }, model: string } | undefined,
}))
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((promiseResolve) => {
@@ -101,6 +114,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
}
})
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
@@ -194,7 +211,7 @@ vi.mock('@/service/client', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: () => ({ data: undefined }),
useDefaultModel: () => ({ data: modelHooksState.defaultTextGenerationModel }),
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
textGenerationModelList: [],
}),
@@ -271,7 +288,7 @@ vi.mock('../components/preview/build-chat', async () => {
void props.onSaveDraftBeforeRun?.().then(() => {
setMessageSent(true)
props.onConversationIdChange?.('build-conversation-new')
})
}).catch(() => undefined)
}}
>
send build message
@@ -359,6 +376,12 @@ vi.mock('../components/preview/versions-panel', () => ({
describe('AgentConfigurePage', () => {
beforeEach(() => {
vi.clearAllMocks()
modelHooksState.defaultTextGenerationModel = {
provider: {
provider: 'langgenius/openai/openai',
},
model: 'gpt-4o-mini',
}
mocks.refreshDebugConversation.mockResolvedValue({
debug_conversation_has_messages: false,
debug_conversation_id: 'debug-conversation-new',
@@ -1036,6 +1059,50 @@ describe('AgentConfigurePage', () => {
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
})
it('should block build chat checkout when no model is configured', async () => {
const queryClient = new QueryClient()
modelHooksState.defaultTextGenerationModel = undefined
mocks.queryState.composer = {
data: {
agent_soul: {
prompt: {
system_prompt: 'draft prompt',
},
},
},
isFetching: false,
isError: false,
isPending: false,
isSuccess: true,
refetch: vi.fn(),
}
mocks.queryState.buildDraft = {
data: undefined as unknown,
dataUpdatedAt: 0,
error: new Response(null, { status: 404 }),
isFetching: false,
isError: true,
isPending: false,
isSuccess: false,
refetch: vi.fn(),
}
render(
<QueryClientProvider client={queryClient}>
<AgentConfigurePage agentId="agent-1" />
</QueryClientProvider>,
)
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
await waitFor(() => {
expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel')
})
expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled()
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:no')
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
})
it('should keep the build draft bar disabled while a build conversation is responding', async () => {
vi.useFakeTimers()
const queryClient = new QueryClient()
@@ -91,6 +91,11 @@ function setDocumentVisibilityState(visibilityState: DocumentVisibilityState) {
})
}
const configuredModel = {
provider: 'langgenius/openai/openai',
model: 'gpt-4o-mini',
}
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMock,
}))
@@ -607,7 +612,9 @@ describe('useAgentConfigureSync', () => {
})
it('should publish only when publishDraft is called explicitly', async () => {
const { queryClient, result, store } = renderUseAgentConfigureSync()
const { queryClient, result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
queryClient.setQueryData(['agent-detail', 'agent-1'], {
active_config_is_published: false,
@@ -654,12 +661,28 @@ describe('useAgentConfigureSync', () => {
expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess')
})
it('should toast and skip publish when no model is configured', async () => {
const { result, store } = renderUseAgentConfigureSync()
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Published prompt',
})
})
await act(async () => {
await result.current.publishDraft()
})
expect(composerPutMutationFn).not.toHaveBeenCalled()
expect(publishAgentMutationFn).not.toHaveBeenCalled()
expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel')
})
it('should keep default model fallback from creating unpublished changes after publish', async () => {
const { result, store } = renderUseAgentConfigureSync({
currentModel: {
provider: 'langgenius/openai/openai',
model: 'gpt-4o-mini',
},
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
@@ -681,6 +704,7 @@ describe('useAgentConfigureSync', () => {
it('should keep base config fallback fields from creating unpublished changes after publish', async () => {
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
baseConfig: {
app_features: {
file_upload: {
@@ -708,7 +732,9 @@ describe('useAgentConfigureSync', () => {
})
it('should publish the current draft snapshot instead of a stale caller payload', async () => {
const { result, store } = renderUseAgentConfigureSync()
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
@@ -736,7 +762,9 @@ describe('useAgentConfigureSync', () => {
it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => {
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
const { queryClient, result, store } = renderUseAgentConfigureSync()
const { queryClient, result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
queryClient.setQueryData(['agent-detail', 'agent-1'], {
active_config_is_published: false,
name: 'Agent',
@@ -760,7 +788,9 @@ describe('useAgentConfigureSync', () => {
})
it('should toast and skip publish when knowledge retrieval validation fails', async () => {
const { result, store } = renderUseAgentConfigureSync()
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
@@ -785,7 +815,9 @@ describe('useAgentConfigureSync', () => {
})
it('should toast metadata filtering model error when publishing with automatic metadata filtering and no model', async () => {
const { result, store } = renderUseAgentConfigureSync()
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
@@ -813,7 +845,9 @@ describe('useAgentConfigureSync', () => {
it('should expose publishing status from the publish mutation while publish is pending', async () => {
const publishDeferred = createDeferredPromise<PublishAgentResponse>()
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
const { result } = renderUseAgentConfigureSync()
const { result } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
let publishPromise!: Promise<void>
act(() => {
publishPromise = result.current.publishDraft()

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