Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48e95bc1c2 | ||
|
|
4f777ca6e1 | ||
|
|
d22232d577 | ||
|
|
ac158193e2 | ||
|
|
a7139e90e4 | ||
|
|
1c3f5b10d9 | ||
|
|
4f039c218c | ||
|
|
7acf2a61df | ||
|
|
9e92e804aa | ||
|
|
5eddb77fb2 | ||
|
|
b1c4067e71 | ||
|
|
460e380fee | ||
|
|
0adf95a4df | ||
|
|
6171810ba5 | ||
|
|
c1d98f64bb | ||
|
|
afdefad6bc | ||
|
|
b2160b4f56 | ||
|
|
8208b786ee | ||
|
|
3f0bb9ecc1 | ||
|
|
4328231bd5 | ||
|
|
a9cd1aaaa2 | ||
|
|
b62a349ab5 | ||
|
|
98b9d8feac | ||
|
|
531095e195 | ||
|
|
c954b27d0a | ||
|
|
4779144dc2 | ||
|
|
5e75ffa03f | ||
|
|
d8bea4f889 | ||
|
|
d9c99daf29 | ||
|
|
8e1ea9d336 | ||
|
|
93eb6d32b5 | ||
|
|
f8b1d9ab95 | ||
|
|
3f314aeeaa | ||
|
|
de5af6c2da | ||
|
|
586c8de1a0 | ||
|
|
0313c55638 | ||
|
|
5cf2bf7044 | ||
|
|
e3457e013d | ||
|
|
59bc1a7fb2 | ||
|
|
e96eb2e2d5 | ||
|
|
708c962724 | ||
|
|
0ed3a1f866 | ||
|
|
e0e33feabb | ||
|
|
bf6eecfd75 | ||
|
|
855e653486 | ||
|
|
01d39e0ed4 |
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."""
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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", {})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+52
-2
@@ -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,
|
||||
)
|
||||
@@ -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")]),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+22
-3
@@ -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()
|
||||
|
||||
+44
-10
@@ -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()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { useAgentConfigureData } from '../hooks'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
@@ -214,6 +215,7 @@ function AgentConfigurePageComposerContent({
|
||||
activeConfigSnapshot,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false)
|
||||
const [clearPreviewChat, setClearPreviewChat] = useState(false)
|
||||
const [completedBuildConversationId, setCompletedBuildConversationId] = useState<string | null>(null)
|
||||
@@ -333,6 +335,7 @@ function AgentConfigurePageComposerContent({
|
||||
isBuildDraftActive={buildDraft.isActive}
|
||||
buildDraftChangedKeys={buildDraft.changedKeys}
|
||||
showPublishBar={!buildDraft.isActive}
|
||||
workflowReferencesEnabled={agentQuery.isSuccess}
|
||||
bottomAction={showBuildDraftBar
|
||||
? (
|
||||
<AgentBuildDraftBar
|
||||
@@ -407,6 +410,11 @@ function AgentConfigurePageComposerContent({
|
||||
}}
|
||||
onSaveDraftBeforeRun={rightPanelChatMode === 'build'
|
||||
? async () => {
|
||||
if (!currentModel?.provider || !currentModel.model) {
|
||||
toast.error(tCommon('modelProvider.selectModel'))
|
||||
throw new Error('Agent model is required.')
|
||||
}
|
||||
|
||||
setBuildDraftActionsDisabled(true)
|
||||
try {
|
||||
return await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
|
||||
+27
-1
@@ -74,8 +74,9 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
referencingWorkflows: {
|
||||
get: {
|
||||
queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({
|
||||
queryOptions: ({ enabled = true, input }: { enabled?: boolean, input: { params: { agent_id: string } } }) => ({
|
||||
queryKey: ['agent-referencing-workflows', input],
|
||||
enabled,
|
||||
queryFn: async () => ({
|
||||
data: (workflowReferences.fetchCount++, workflowReferences.data),
|
||||
}),
|
||||
@@ -162,6 +163,7 @@ function renderPublishBar({
|
||||
selectedVersionSnapshot,
|
||||
setupStore,
|
||||
usedByAppReferences = [],
|
||||
workflowReferencesEnabled,
|
||||
}: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
@@ -173,6 +175,7 @@ function renderPublishBar({
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
setupStore?: (store: ReturnType<typeof createStore>) => void
|
||||
usedByAppReferences?: AgentReferencingWorkflowResponse[]
|
||||
workflowReferencesEnabled?: boolean
|
||||
} = {}) {
|
||||
workflowReferences.data = usedByAppReferences
|
||||
const queryClient = new QueryClient({
|
||||
@@ -200,6 +203,7 @@ function renderPublishBar({
|
||||
agentName="Iris"
|
||||
isPublishing={nextProps?.isPublishing ?? isPublishing}
|
||||
selectedVersionSnapshot={selectedVersionSnapshot}
|
||||
workflowReferencesEnabled={workflowReferencesEnabled}
|
||||
onPublish={onPublish}
|
||||
onExitVersions={onExitVersions}
|
||||
onOpenVersions={vi.fn()}
|
||||
@@ -407,6 +411,28 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish without loading workflow references when references are disabled', async () => {
|
||||
const { onPublish } = renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
prompt: 'Updated system prompt',
|
||||
usedByAppReferences: publishedReferences,
|
||||
workflowReferencesEnabled: false,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowReferences.fetchCount).toBe(0)
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(workflowReferences.fetchCount).toBe(0)
|
||||
expect(screen.queryByRole('region', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
|
||||
})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark non-prompt draft changes as unpublished', () => {
|
||||
renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user