From 5aa2092d0fe308e9cd2892e1ef809446b65abd52 Mon Sep 17 00:00:00 2001 From: SpiliosDmk <150211937+SpiliosDimakopoulos@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:36:17 +0300 Subject: [PATCH 001/531] chore: Grammar fix in VDB provider README ("a importable") (#39577) --- api/providers/vdb/README.md | 2 +- dify-agent-runtime/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/providers/vdb/README.md b/api/providers/vdb/README.md index b5b4197f63c..e398cb938b9 100644 --- a/api/providers/vdb/README.md +++ b/api/providers/vdb/README.md @@ -29,7 +29,7 @@ In `pyproject.toml`: pgvector = "dify_vdb_pgvector.pgvector:PGVectorFactory" ``` -The value is **`module:attribute`**: a importable module path and the class implementing `AbstractVectorFactory`. +The value is **`module:attribute`**: an importable module path and the class implementing `AbstractVectorFactory`. ### How registration works diff --git a/dify-agent-runtime/README.md b/dify-agent-runtime/README.md index 49ecc5890bf..612abb13c21 100644 --- a/dify-agent-runtime/README.md +++ b/dify-agent-runtime/README.md @@ -41,7 +41,7 @@ docker build -f dify-agent-runtime/docker/Dockerfile \ dify-agent-runtime/ ``` -### Runing docker container +### Running docker container ``` docker run -d --name dify-agent-runtime \ From 4a44befb0018cff231ecfcd44b77fa6d899515b4 Mon Sep 17 00:00:00 2001 From: SpiliosDmk <150211937+SpiliosDimakopoulos@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:36:05 +0300 Subject: [PATCH 002/531] chore: Broken docker-compose.yaml link in Hindi README (missing slash) (#39576) --- docs/hi-IN/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/hi-IN/README.md b/docs/hi-IN/README.md index e20cb9c9845..118f413b007 100644 --- a/docs/hi-IN/README.md +++ b/docs/hi-IN/README.md @@ -75,7 +75,7 @@ Dify एक मुक्त-स्रोत प्लेटफ़ॉर्म
-Dify सर्वर शुरू करने का सबसे आसान तरीका [Docker Compose](../..docker/docker-compose.yaml) के माध्यम से है। नीचे दिए गए कमांड्स से Dify चलाने से पहले, सुनिश्चित करें कि आपकी मशीन पर [Docker] (https://docs.docker.com/get-docker/) और [Docker Compose] (https://docs.docker.com/compose/install/) इंस्टॉल हैं।: +Dify सर्वर शुरू करने का सबसे आसान तरीका [Docker Compose](../../docker/docker-compose.yaml) के माध्यम से है। नीचे दिए गए कमांड्स से Dify चलाने से पहले, सुनिश्चित करें कि आपकी मशीन पर [Docker] (https://docs.docker.com/get-docker/) और [Docker Compose] (https://docs.docker.com/compose/install/) इंस्टॉल हैं।: ```bash cd dify From ac0320a70df90673eeec53080d0b1f42f517ff70 Mon Sep 17 00:00:00 2001 From: SpiliosDmk <150211937+SpiliosDimakopoulos@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:50:52 +0300 Subject: [PATCH 003/531] docs: fix typos in ValueSourceType docstring (#39573) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/workflow/nodes/human_input/enums.py | 4 ++-- api/openapi/markdown/console-openapi.md | 4 ++-- api/openapi/markdown/service-openapi.md | 4 ++-- api/openapi/markdown/web-openapi.md | 4 ++-- packages/contracts/generated/api/console/agent/zod.gen.ts | 2 +- packages/contracts/generated/api/console/apps/zod.gen.ts | 2 +- .../contracts/generated/api/console/installed-apps/zod.gen.ts | 2 +- packages/contracts/generated/api/service/zod.gen.ts | 2 +- packages/contracts/generated/api/web/zod.gen.ts | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/api/core/workflow/nodes/human_input/enums.py b/api/core/workflow/nodes/human_input/enums.py index 53a3bd0b964..da8406d9cf2 100644 --- a/api/core/workflow/nodes/human_input/enums.py +++ b/api/core/workflow/nodes/human_input/enums.py @@ -67,10 +67,10 @@ class FormInputType(enum.StrEnum): class ValueSourceType(enum.StrEnum): """ValueSourceType records whether the value comes from a static setting - in form definiton, or a variable while the workflow is running. + in form definition, or a variable while the workflow is running. """ # `VARIABLE` means that the value comes from a variable in workflow execution VARIABLE = enum.auto() - # `CONSTANT` measn that the value comes from a static setting in form definition. + # `CONSTANT` means that the value comes from a static setting in form definition. CONSTANT = enum.auto() diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index a40ee97685d..ef38f3c891d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -22993,11 +22993,11 @@ User action configuration. #### ValueSourceType ValueSourceType records whether the value comes from a static setting -in form definiton, or a variable while the workflow is running. +in form definition, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | | +| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definition, or a variable while the workflow is running. | | #### VerificationTokenResponse diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 3cc8e4c3410..00515af57e7 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -4052,11 +4052,11 @@ User action configuration. #### ValueSourceType ValueSourceType records whether the value comes from a static setting -in form definiton, or a variable while the workflow is running. +in form definition, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | | +| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definition, or a variable while the workflow is running. | | #### WeightKeywordSetting diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 4b292991a63..2ba8c35dd17 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1600,11 +1600,11 @@ User action configuration. #### ValueSourceType ValueSourceType records whether the value comes from a static setting -in form definiton, or a variable while the workflow is running. +in form definition, or a variable while the workflow is running. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | | +| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definition, or a variable while the workflow is running. | | #### VerificationTokenResponse diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 4146d261ffc..74d05d36775 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -2528,7 +2528,7 @@ export const zAgentConfigSnapshotDetailResponse = z.object({ * ValueSourceType * * ValueSourceType records whether the value comes from a static setting - * in form definiton, or a variable while the workflow is running. + * in form definition, or a variable while the workflow is running. */ export const zValueSourceType = z.enum(['constant', 'variable']) diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index a41672798e6..88dcfc13682 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -3895,7 +3895,7 @@ export const zAgentKnowledgeRetrievalConfig = z.object({ * ValueSourceType * * ValueSourceType records whether the value comes from a static setting - * in form definiton, or a variable while the workflow is running. + * in form definition, or a variable while the workflow is running. */ export const zValueSourceType = z.enum(['constant', 'variable']) diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index f0acbe06c0e..8be095ef6d4 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -430,7 +430,7 @@ export const zFileListInputConfig = z.object({ * ValueSourceType * * ValueSourceType records whether the value comes from a static setting - * in form definiton, or a variable while the workflow is running. + * in form definition, or a variable while the workflow is running. */ export const zValueSourceType = z.enum(['constant', 'variable']) diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 78fcc65cdad..9e34890b435 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -1894,7 +1894,7 @@ export const zUserActionConfig = z.object({ * ValueSourceType * * ValueSourceType records whether the value comes from a static setting - * in form definiton, or a variable while the workflow is running. + * in form definition, or a variable while the workflow is running. */ export const zValueSourceType = z.enum(['constant', 'variable']) diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index f41de0e0077..3dbb9584950 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -641,7 +641,7 @@ export const zUserActionConfig = z.object({ * ValueSourceType * * ValueSourceType records whether the value comes from a static setting - * in form definiton, or a variable while the workflow is running. + * in form definition, or a variable while the workflow is running. */ export const zValueSourceType = z.enum(['constant', 'variable']) From 1d341849997c97d9ad47413237a6c35c56f68dba Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Sun, 26 Jul 2026 13:08:58 +0800 Subject: [PATCH 004/531] chore(agent): update && simplify readme (#39584) --- dify-agent-runtime/README.md | 41 +++++++----------------------------- 1 file changed, 8 insertions(+), 33 deletions(-) diff --git a/dify-agent-runtime/README.md b/dify-agent-runtime/README.md index 612abb13c21..59b1d451167 100644 --- a/dify-agent-runtime/README.md +++ b/dify-agent-runtime/README.md @@ -2,21 +2,16 @@ Go implementation of the shellctl server and runtime utilities. -This is a rewrite of the Python `shellctl` package (`dify-agent/src/shellctl/` and -`dify-agent/src/shellctl_runtime/`). The original Python code is kept as reference. - ## Architecture ``` cmd/ - shellctl/ — main server binary (shellctl serve) - sanitize-pty/ — tmux pipe-pane PTY sanitizer (stdin→stdout filter) - runner-exit/ — post-drain SQLite exit recorder - -internal/ - sanitize/ — PTY ANSI stripping + CR normalization - runner_exit/ — SQLite CAS update for job exit - server/ — HTTP API, job service, tmux controller, output reader + shellctl/ - main server binary (shellctl serve) + sanitize-pty/ - tmux pipe-pane PTY sanitizer (stdin→stdout filter) + runner-exit/ - post-drain SQLite exit recorder + dify-agent-cli/ - cli tool talking to agent backend + runner/ - process runner to bootstrap agent commands +internal/ - internal implementations ``` ## Building @@ -27,11 +22,6 @@ make build Produces binaries in `bin/`: -- `shellctl` — the main server (`shellctl serve --listen 0.0.0.0:5004`) -- `shellctl-sanitize-pty` — PTY sanitizer for tmux pipe-pane -- `shellctl-runner-exit` — exit state writer -- `shellctl-runner` — job runner with integrated Landlock isolation - ### Building docker image ``` @@ -78,27 +68,12 @@ The runner automatically creates `$CWD/.tmp` and sets `TMPDIR`, `TMP`, `TEMP` to ### Environment Variables -| Variable | Default | Description | -| -------------------------------- | --------------- | ------------------------------------------------ | -| `SHELLCTL_ENABLE_PATH_ISOLATION` | `true` | Set to `false` to disable Landlock entirely | -| `SHELLCTL_LANDLOCK_RW_PATHS` | _(empty)_ | Comma-separated RW directories (besides `$HOME`) | -| `SHELLCTL_LANDLOCK_RO_PATHS` | `/usr,/bin,...` | Comma-separated RO+exec directories | -| `SHELLCTL_LANDLOCK_RW_DEV_PATHS` | `/dev/null,...` | Comma-separated device files with RW access | +See [here](./internal/envvar/envvar.go) Requires Linux ≥ 5.13. On unsupported kernels, a warning is printed to stderr. ## Dependencies -- Go 1.23+ +- Go 1.26 - `modernc.org/sqlite` (pure-Go SQLite driver, no CGO required) - tmux (runtime dependency, not a build dependency) - -## Migration from Python - -The Go binaries are drop-in replacements for the Python console scripts: - -- `shellctl-sanitize-pty` replaces the Python `shellctl-sanitize-pty` entrypoint -- `shellctl-runner-exit` replaces the Python `shellctl-runner-exit` entrypoint -- `shellctl serve` replaces the Python `shellctl serve` (FastAPI/uvicorn) - -The HTTP API contract, SQLite schema, and filesystem artifact layout are identical. From fd2e26f8e6705af416154d4e3a5a4a0cdf348376 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:13:37 +0800 Subject: [PATCH 005/531] refactor(agents): simplify repository context (#39583) --- .agents/skills/backend-code-review/SKILL.md | 176 ++------ .../references/architecture-rule.md | 5 +- .../references/db-schema-rule.md | 5 - .../references/repositories-rule.md | 1 - .../references/sqlalchemy-rule.md | 6 +- .../skills/e2e-cucumber-playwright/SKILL.md | 90 +--- .../references/cucumber-best-practices.md | 35 +- .../references/playwright-best-practices.md | 26 +- .agents/skills/frontend-code-review/SKILL.md | 110 ++--- .../references/component-architecture.md | 5 +- .../references/data-query-contracts.md | 2 +- .../references/dify-ui.md | 2 +- .agents/skills/frontend-testing/SKILL.md | 37 +- .../skills/how-to-write-component/SKILL.md | 155 ++----- .../how-to-write-component/references/data.md | 42 ++ .../references/interactions.md | 31 ++ .../references/ownership.md | 39 ++ .../references/runtime.md | 23 + .../references/state.md | 38 ++ .agents/skills/karpathy-guidelines/SKILL.md | 33 -- .claude/skills/component-refactoring | 1 - .claude/skills/karpathy-guidelines | 1 - AGENTS.md | 46 +- api/AGENTS.md | 237 +--------- api/controllers/API_SCHEMA_GUIDE.md | 3 + cli/AGENTS.md | 103 +---- cli/ARD.md | 114 ++--- cli/src/commands/AGENTS.md | 10 +- dify-agent/AGENTS.md | 189 +------- e2e/AGENTS.md | 403 ++---------------- packages/dify-ui/AGENTS.md | 106 +---- packages/dify-ui/README.md | 30 +- web/AGENTS.md | 59 +-- 33 files changed, 498 insertions(+), 1665 deletions(-) create mode 100644 .agents/skills/how-to-write-component/references/data.md create mode 100644 .agents/skills/how-to-write-component/references/interactions.md create mode 100644 .agents/skills/how-to-write-component/references/ownership.md create mode 100644 .agents/skills/how-to-write-component/references/runtime.md create mode 100644 .agents/skills/how-to-write-component/references/state.md delete mode 100644 .agents/skills/karpathy-guidelines/SKILL.md delete mode 120000 .claude/skills/component-refactoring delete mode 120000 .claude/skills/karpathy-guidelines diff --git a/.agents/skills/backend-code-review/SKILL.md b/.agents/skills/backend-code-review/SKILL.md index 35dc54173e4..66ab8dc6099 100644 --- a/.agents/skills/backend-code-review/SKILL.md +++ b/.agents/skills/backend-code-review/SKILL.md @@ -1,168 +1,40 @@ --- name: backend-code-review -description: Review backend code for quality, security, maintainability, and best practices based on established checklist rules. Use when the user requests a review, analysis, or improvement of backend files (e.g., `.py`) under the `api/` directory. Do NOT use for frontend files (e.g., `.tsx`, `.ts`, `.js`). Supports pending-change review, code snippets review, and file-focused review. +description: Use only when the user explicitly requests a review or audit of backend code under `api/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for implementation-only requests, diagnosis without review intent, frontend code, or backend code outside `api/`. --- # Backend Code Review -## When to use this skill +Review the requested scope for concrete, reproducible defects. The nearest `AGENTS.md` owns package facts and commands; this skill owns the review workflow and routes to its bundled rule packs. -Use this skill whenever the user asks to **review, analyze, or improve** backend code (e.g., `.py`) under the `api/` directory. Supports the following review modes: +## Evidence First -- **Pending-change review**: when the user asks to review current changes (inspect staged/working-tree files slated for commit to get the changes). -- **Code snippets review**: when the user pastes code snippets (e.g., a function/class/module excerpt) into the chat and asks for a review. -- **File-focused review**: when the user points to specific files and asks for a review of those files (one file or a small, explicit set of files, e.g., `api/...`, `api/app.py`). +1. Establish the requested review scope and inspect the relevant diff or files. +2. Read the changed lines, their behavior owner, nearby tests, and local docstrings or comments that define contracts. +3. Trace callers, persistence boundaries, authorization, generated schemas, or external I/O only when they decide correctness. +4. Report only findings tied to an observable failure, violated contract, security boundary, data integrity risk, or demonstrated maintenance problem. -Do NOT use this skill when: +## Rule Routing -- The request is about frontend code or UI (e.g., `.tsx`, `.ts`, `.js`, `web/`). -- The user is not asking for a review/analysis/improvement of backend code. -- The scope is not under `api/` (unless the user explicitly asks to review backend-related changes outside `api/`). +Read only the packs matched by the diff: -## How to use this skill +- Models or migrations: [`references/db-schema-rule.md`][db-schema] +- Controller, service, core/domain, library, or model dependency direction: [`references/architecture-rule.md`][architecture] +- Table access outside an established repository boundary: [`references/repositories-rule.md`][repositories] +- SQLAlchemy sessions, queries, transactions, CRUD, concurrency, or raw SQL: [`references/sqlalchemy-rule.md`][sqlalchemy] -Follow these steps when using this skill: +When no pack applies, review correctness, security, behavior changes, and test evidence directly. Check current official documentation only when local code and contracts do not settle framework or library behavior. -1. **Identify the review mode** (pending-change vs snippet vs file-focused) based on the user’s input. Keep the scope tight: review only what the user provided or explicitly referenced. -2. Follow the rules defined in **Checklist** to perform the review. If no Checklist rule matches, apply **General Review Rules** as a fallback to perform the best-effort review. -3. Compose the final output strictly follow the **Required Output Format**. +## Severity And Output -Notes when using this skill: -- Always include actionable fixes or suggestions (including possible code snippets). -- Use best-effort `File:Line` references when a file path and line numbers are available; otherwise, use the most specific identifier you can. +- **P0**: security or privacy exposure, data loss, or a production-wide outage. +- **P1**: user-visible regression, broken authorization or tenant isolation, invalid public contract, or failed primary workflow. +- **P2**: concrete correctness, performance, maintainability, or test defect likely to cause incorrect behavior. +- **P3**: minor actionable cleanup; omit unless the user requested a thorough audit. -## Checklist +Lead with findings ordered by severity. Include a tight file and line reference, the failing contract or reproduction path, impact, and a concrete fix direction. If there are no findings, say `No issues found.` and state any material verification gap. Do not add praise sections, speculative risks, or an unsolicited offer to implement fixes. -- db schema design: if the review scope includes code/files under `api/models/` or `api/migrations/`, follow [references/db-schema-rule.md](references/db-schema-rule.md) to perform the review -- architecture: if the review scope involves controller/service/core-domain/libs/model layering, dependency direction, or moving responsibilities across modules, follow [references/architecture-rule.md](references/architecture-rule.md) to perform the review -- repositories abstraction: if the review scope contains table/model operations (e.g., `select(...)`, `session.execute(...)`, joins, CRUD) and is not under `api/repositories`, `api/core/repositories`, or `api/extensions/*/repositories/`, follow [references/repositories-rule.md](references/repositories-rule.md) to perform the review -- sqlalchemy patterns: if the review scope involves SQLAlchemy session/query usage, db transaction/crud usage, or raw SQL usage, follow [references/sqlalchemy-rule.md](references/sqlalchemy-rule.md) to perform the review - -## General Review Rules - -### 1. Security Review - -Check for: -- SQL injection vulnerabilities -- Server-Side Request Forgery (SSRF) -- Command injection -- Insecure deserialization -- Hardcoded secrets/credentials -- Improper authentication/authorization -- Insecure direct object references - -### 2. Performance Review - -Check for: -- N+1 queries -- Missing database indexes -- Memory leaks -- Blocking operations in async code -- Missing caching opportunities - -### 3. Code Quality Review - -Check for: -- Code forward compatibility -- Code duplication (DRY violations) -- Functions doing too much (SRP violations) -- Deep nesting / complex conditionals -- Magic numbers/strings -- Poor naming -- Missing error handling -- Incomplete type coverage - -### 4. Testing Review - -Check for: -- Missing test coverage for new code -- Tests that don't test behavior -- Flaky test patterns -- Missing edge cases - -## Required Output Format - -When this skill invoked, the response must exactly follow one of the two templates: - -### Template A (any findings) - -```markdown -# Code Review Summary - -Found critical issues need to be fixed: - -## 🔴 Critical (Must Fix) - -### 1. - -FilePath: line - - -#### Explanation - - - -#### Suggested Fix - -1. -2. (optional, omit if not applicable) - ---- -... (repeat for each critical issue) ... - -Found suggestions for improvement: - -## 🟡 Suggestions (Should Consider) - -### 1. - -FilePath: line - - -#### Explanation - - - -#### Suggested Fix - -1. -2. (optional, omit if not applicable) - ---- -... (repeat for each suggestion) ... - -Found optional nits: - -## 🟢 Nits (Optional) -### 1. - -FilePath: line - - -#### Explanation - - - -#### Suggested Fix - -- - ---- -... (repeat for each nits) ... - -## ✅ What's Good - -- -``` - -- If there are no critical issues or suggestions or option nits or good points, just omit that section. -- If the issue number is more than 10, summarize as "Found 10+ critical issues/suggestions/optional nits" and only output the first 10 items. -- Don't compress the blank lines between sections; keep them as-is for readability. -- If there is any issue requires code changes, append a brief follow-up question to ask whether the user wants to apply the fix(es) after the structured output. For example: "Would you like me to use the Suggested fix(es) to address these issues?" - -### Template B (no issues) - -```markdown -## Code Review Summary -✅ No issues found. -``` \ No newline at end of file +[architecture]: references/architecture-rule.md +[db-schema]: references/db-schema-rule.md +[repositories]: references/repositories-rule.md +[sqlalchemy]: references/sqlalchemy-rule.md diff --git a/.agents/skills/backend-code-review/references/architecture-rule.md b/.agents/skills/backend-code-review/references/architecture-rule.md index c3fd08bf033..02ee49d8fd9 100644 --- a/.agents/skills/backend-code-review/references/architecture-rule.md +++ b/.agents/skills/backend-code-review/references/architecture-rule.md @@ -7,7 +7,6 @@ ### Keep business logic out of controllers - Category: maintainability -- Severity: critical - Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test. - Suggested fix: Move domain/business logic into the service or core/domain layer. Keep controller handlers thin and orchestration-focused. - Example: @@ -34,7 +33,6 @@ ### Preserve layer dependency direction - Category: best practices -- Severity: critical - Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code. - Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse. - Example: @@ -58,7 +56,6 @@ ### Keep libs business-agnostic - Category: maintainability -- Severity: critical - Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions. - Suggested fix: - If business logic appears in `api/libs/`, extract it into the appropriate `services/` or `core/` module and keep `libs` focused on generic, cross-cutting helpers. @@ -88,4 +85,4 @@ def should_archive_conversation(conversation, tenant_id: str) -> bool: threshold_days = 90 if has_paid_plan(tenant_id) else 30 return older_than_days(conversation.idle_days, threshold_days) - ``` \ No newline at end of file + ``` diff --git a/.agents/skills/backend-code-review/references/db-schema-rule.md b/.agents/skills/backend-code-review/references/db-schema-rule.md index 8feae2596a1..8f922bc3dc0 100644 --- a/.agents/skills/backend-code-review/references/db-schema-rule.md +++ b/.agents/skills/backend-code-review/references/db-schema-rule.md @@ -8,7 +8,6 @@ ### Do not query other tables inside `@property` - Category: [maintainability, performance] -- Severity: critical - Description: A model `@property` must not open sessions or query other tables. This hides dependencies across models, tightly couples schema objects to data access, and can cause N+1 query explosions when iterating collections. - Suggested fix: - Keep model properties pure and local to already-loaded fields. @@ -41,7 +40,6 @@ ### Prefer including `tenant_id` in model definitions - Category: maintainability -- Severity: suggestion - Description: In multi-tenant domains, include `tenant_id` in schema definitions whenever the entity belongs to tenant-owned data. This improves data isolation safety and keeps future partitioning/sharding strategies practical as data volume grows. - Suggested fix: - Add a `tenant_id` column and ensure related unique/index constraints include tenant dimension when applicable. @@ -70,7 +68,6 @@ ### Detect and avoid duplicate/redundant indexes - Category: performance -- Severity: suggestion - Description: Review index definitions for leftmost-prefix redundancy. For example, index `(a, b, c)` can safely cover most lookups for `(a, b)`. Keeping both may increase write overhead and can mislead the optimizer into suboptimal execution plans. - Suggested fix: - Before adding an index, compare against existing composite indexes by leftmost-prefix rules. @@ -94,7 +91,6 @@ ### Avoid PostgreSQL-only dialect usage in models; wrap in `models.types` - Category: maintainability -- Severity: critical - Description: Model/schema definitions should avoid PostgreSQL-only constructs directly in business models. When database-specific behavior is required, encapsulate it in `api/models/types.py` using both PostgreSQL and MySQL dialect implementations, then consume that abstraction from model code. - Suggested fix: - Do not directly place dialect-only types/operators in model columns when a portable wrapper can be used. @@ -122,7 +118,6 @@ ### Guard migration incompatibilities with dialect checks and shared types - Category: maintainability -- Severity: critical - Description: Migration scripts under `api/migrations/versions/` must account for PostgreSQL/MySQL incompatibilities explicitly. For dialect-sensitive DDL or defaults, branch on the active dialect (for example, `conn.dialect.name == "postgresql"`), and prefer reusable compatibility abstractions from `models.types` where applicable. - Suggested fix: - In migration upgrades/downgrades, bind connection and branch by dialect for incompatible SQL fragments. diff --git a/.agents/skills/backend-code-review/references/repositories-rule.md b/.agents/skills/backend-code-review/references/repositories-rule.md index 555de98eb04..c0f16a21282 100644 --- a/.agents/skills/backend-code-review/references/repositories-rule.md +++ b/.agents/skills/backend-code-review/references/repositories-rule.md @@ -8,7 +8,6 @@ ### Introduce repositories abstraction - Category: maintainability -- Severity: suggestion - Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation. - Suggested fix: - First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access. diff --git a/.agents/skills/backend-code-review/references/sqlalchemy-rule.md b/.agents/skills/backend-code-review/references/sqlalchemy-rule.md index cda3a5dc98d..2ed3be4bbb5 100644 --- a/.agents/skills/backend-code-review/references/sqlalchemy-rule.md +++ b/.agents/skills/backend-code-review/references/sqlalchemy-rule.md @@ -8,7 +8,6 @@ ### Use Session context manager with explicit transaction control behavior - Category: best practices -- Severity: critical - Description: Session and transaction lifecycle must be explicit and bounded on write paths. Missing commits can silently drop intended updates, while ad-hoc or long-lived transactions increase contention, lock duration, and deadlock risk. - Suggested fix: - Use **explicit `session.commit()`** after completing a related write unit. @@ -47,7 +46,6 @@ ### Enforce tenant_id scoping on shared-resource queries - Category: security -- Severity: critical - Description: Reads and writes against shared tables must be scoped by `tenant_id` to prevent cross-tenant data leakage or corruption. - Suggested fix: Add `tenant_id` predicate to all tenant-owned entity queries and propagate tenant context through service/repository interfaces. - Example: @@ -67,7 +65,6 @@ ### Prefer SQLAlchemy expressions over raw SQL by default - Category: maintainability -- Severity: suggestion - Description: Raw SQL should be exceptional. ORM/Core expressions are easier to evolve, safer to compose, and more consistent with the codebase. - Suggested fix: Rewrite straightforward raw SQL into SQLAlchemy `select/update/delete` expressions; keep raw SQL only when required by clear technical constraints. - Example: @@ -89,7 +86,6 @@ ### Protect write paths with concurrency safeguards - Category: quality -- Severity: critical - Description: Multi-writer paths without explicit concurrency control can silently overwrite data. Choose the safeguard based on contention level, lock scope, and throughput cost instead of defaulting to one strategy. - Suggested fix: - **Optimistic locking**: Use when contention is usually low and retries are acceptable. Add a version (or updated_at) guard in `WHERE` and treat `rowcount == 0` as a conflict. @@ -136,4 +132,4 @@ ).scalar_one() run.status = "cancelled" session.commit() - ``` \ No newline at end of file + ``` diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index 5762bf2076d..62c2012dce8 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -1,87 +1,31 @@ --- name: e2e-cucumber-playwright -description: Write, update, or review Dify end-to-end tests under `e2e/` that use Cucumber, Gherkin, and Playwright. Use when the task involves `.feature` files, `features/step-definitions/`, `features/support/`, `DifyWorld`, scenario tags, locator/assertion choices, or E2E testing best practices for this repository. +description: Use when writing, changing, or reviewing Cucumber and Playwright tests under `e2e/`, including feature files, step definitions, support code, scenario tags, locators, and assertions. Do not use for Vitest, React Testing Library, backend tests, or generic browser automation outside the E2E suite. --- -# Dify E2E Cucumber + Playwright +# E2E Cucumber And Playwright -Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) as the canonical package guide for local architecture and conventions, then read any feature-scoped `AGENTS.md` that owns the target area. Apply Playwright/Cucumber best practices only where they fit the current suite. +`e2e/AGENTS.md` owns the suite architecture, lifecycle, commands, tags, generated-client boundaries, fixtures, and cleanup contracts. Read the nearest feature-scoped `AGENTS.md` when one exists. This skill adds no parallel package policy. -## Scope +## Topic Routing -- Use this skill for `.feature` files, Cucumber step definitions, `DifyWorld`, hooks, tags, and E2E review work under `e2e/`. -- Do not use this skill for Vitest or React Testing Library work under `web/`; use `frontend-testing` instead. -- Do not use this skill for backend test or API review tasks under `api/`. +Read only the bundled reference required by the change: -## Read Order +- Locator, assertion, isolation, or waiting decisions: [`references/playwright-best-practices.md`][playwright] +- Scenario wording, step granularity, expressions, or tag design: [`references/cucumber-best-practices.md`][cucumber] -1. Read [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) first. -2. Read only the files directly involved in the task: - - target `.feature` files under `e2e/features/` - - related step files under `e2e/features/step-definitions/` - - `e2e/features/support/hooks.ts` and `e2e/features/support/world.ts` when session lifecycle or shared state matters - - `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter -3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved. -4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved. -5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern. - -Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. Put feature-specific conventions in the owning feature's `AGENTS.md` instead of adding them here. - -## Local Rules - -- `e2e/` uses Cucumber for scenarios and Playwright as the browser layer. -- `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions. -- Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps. -- Treat `e2e/AGENTS.md`, `features/support/hooks.ts`, and the Cucumber configuration as the owners of current session and tag semantics. Verify them when behavior depends on session state instead of copying a tag inventory into this skill. -- Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture. -- Perform the behavior under test through Playwright. APIs are allowed for setup, seed preparation, persistence polling, and cleanup, but ordinary Console JSON and representable multipart operations must use the scenario- or process-owned generated oRPC client with request and response validation enabled. Keep the setup/cleanup API identity independent from an unauthenticated or logged-out behavior browser. -- Consume generated operations directly. Do not add one-to-one API wrappers, handwritten endpoint URLs, response DTO casts, duplicate schemas, global mutable clients, or TanStack Query caching in Cucumber. Keep helpers only for real fixture construction, multi-operation orchestration, invariants, polling, derived test views, or protocol adapters. -- Keep SSE, binary, redirect-only, external-service, and readiness exceptions centralized under their protocol owner. A contract mismatch must fail and be fixed at the backend schema owner followed by regeneration; never weaken validation to make E2E pass. +Check current official Playwright or Cucumber documentation before introducing a framework pattern that local code and references do not already establish. ## Workflow -1. Rebuild local context. - - Inspect the target feature area. - - Reuse an existing step when wording and behavior already match. - - Add a new step only for a genuinely new user action or assertion. - - Before adding several similar steps, scan the target capability for an existing domain noun that can be parameterized without hiding behavior. - - Keep edits close to the current capability folder unless the step is broadly reusable. -2. Write behavior-first scenarios. - - Describe user-observable behavior, not DOM mechanics. - - Keep each scenario focused on one workflow or outcome. - - Keep scenarios independent and re-runnable. -3. Write step definitions in the local style. - - Keep one step to one user-visible action or one assertion. - - Prefer Cucumber Expressions such as `{string}` and `{int}`. - - Use a bounded regex only when the accepted values are a small explicit domain set and Cucumber Expressions would make the Gherkin less natural. - - Do not create one-off steps for each case variant when the same domain action or outcome applies to named surfaces, modes, or resources. - - Scope locators to stable containers when the page has repeated elements. - - Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them. -4. Use Playwright in the local style. - - Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts. - - Use web-first `expect(...)` assertions. - - Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior. - - Use `expect.poll` for API persistence, backend eventual consistency, captured browser events, or other non-DOM state; prefer locator assertions for DOM readiness and visible UI state. - - If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id. -5. Validate narrowly. - - Run the narrowest tagged scenario or flow that exercises the change. - - Run the package-required static checks documented in `e2e/AGENTS.md`. - - Broaden verification only when the change affects hooks, tags, setup, or shared step semantics. +1. Add E2E coverage only for a critical user journey with a cross-boundary outcome that cheaper owner-level tests do not already prove. +2. Identify the user-visible behavior and its feature owner. Start from real product defaults and actor roles; setup may establish preconditions but must not manufacture the opposite state to make the scenario meaningful. +3. Read the target scenario, matching step definitions, and lifecycle files only when session or shared state matters. +4. Reuse an existing step when wording and behavior match; add one coherent scenario or step when they do not. +5. Keep browser actions and assertions at the public user boundary; keep setup, seed, polling, and cleanup at their package-defined owners. +6. Run the narrowest tagged scenario and package checks documented in `e2e/AGENTS.md`; broaden only for shared hooks, tags, or support changes. -## Review Checklist +For review requests, lead with reproducible correctness failures, flake sources, or demonstrated architecture drift. Report the behavior verified and any external-runtime, browser, or environment gap. -- Does the scenario describe behavior rather than implementation? -- Does it fit the current session model, tags, and `DifyWorld` usage? -- Should an existing step be reused instead of adding a new one? -- Are locators user-facing and assertions web-first? -- Does the change introduce hidden coupling across scenarios, tags, or instance state? -- Does it document or implement behavior that differs from the real hooks or configuration? -- Does setup/cleanup use the generated client directly, with any remaining helper owning more than a one-to-one endpoint forward? -- Is every raw HTTP call a documented protocol or infrastructure exception rather than an ordinary Console operation? - -Lead findings with correctness, flake risk, and architecture drift. - -## References - -- [`references/playwright-best-practices.md`](references/playwright-best-practices.md) -- [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) +[cucumber]: references/cucumber-best-practices.md +[playwright]: references/playwright-best-practices.md diff --git a/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md b/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md index 06177faa5c7..a02e2e3b0e2 100644 --- a/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md +++ b/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md @@ -1,12 +1,12 @@ -# Cucumber Best Practices For Dify E2E +# Cucumber Best Practices -Use this reference when writing or reviewing Gherkin scenarios, step definitions, parameter expressions, and step reuse in Dify's `e2e/` suite. +Use this reference when writing or reviewing Gherkin scenarios, step definitions, parameter expressions, and step reuse. Official sources: -- https://cucumber.io/docs/guides/10-minute-tutorial/ -- https://cucumber.io/docs/cucumber/step-definitions/ -- https://cucumber.io/docs/cucumber/cucumber-expressions/ +- https://cucumber.io/docs/guides/10-minute-tutorial +- https://cucumber.io/docs/cucumber/step-definitions +- https://cucumber.io/docs/cucumber/cucumber-expressions ## What Matters Most @@ -24,11 +24,7 @@ Apply it like this: A scenario should usually prove one workflow or business outcome. If a scenario wanders across several unrelated behaviors, split it. -In Dify's suite, this means: - -- one capability-focused scenario per feature path -- no long setup chains when existing bootstrap or reusable steps already cover them -- no hidden dependency on another scenario's side effects +Keep each scenario centered on one coherent outcome. Avoid hidden dependencies on another scenario's side effects, and keep unavoidable setup outside the behavior narrative unless the precondition matters to the specification. ### 3. Reuse steps, but only when behavior really matches @@ -68,26 +64,11 @@ Use regex for a bounded natural-language alternative only when it keeps Gherkin Step definitions are glue between Gherkin and automation, not a second abstraction language. -For Dify: - -- type `this` as `DifyWorld` -- use `async function` -- keep each step to one user-visible action or assertion -- rely on `DifyWorld` and existing support code for shared context -- avoid leaking cross-scenario state +Keep each step to one user-visible action or assertion. In JavaScript and TypeScript, use `async function` when the step reads Cucumber World state because Cucumber binds `this`; do not leak state across scenarios through module globals. ### 6. Use tags intentionally -Tags should communicate run scope or session semantics, not become ad hoc metadata. - -In Dify's current suite: - -- capability tags group related scenarios -- `@unauthenticated` changes session behavior -- `@authenticated` is descriptive/selective, not a behavior switch by itself -- `@fresh` belongs to reset/full-install flows only - -If a proposed tag implies behavior, verify that hooks or runner configuration actually implement it. +Tags should communicate selection or execution intent, not become ad hoc metadata. A tag does not change runtime behavior unless configuration or hooks implement it. ## Review Questions diff --git a/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md b/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md index deb29e72430..e6709e00d75 100644 --- a/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md +++ b/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md @@ -1,6 +1,6 @@ -# Playwright Best Practices For Dify E2E +# Playwright Best Practices -Use this reference when writing or reviewing locator, assertion, isolation, or synchronization logic for Dify's Cucumber-based E2E suite. +Use this reference when writing or reviewing locator, assertion, isolation, or synchronization logic. Official sources: @@ -13,20 +13,19 @@ Official sources: ### 1. Keep scenarios isolated -Playwright's model is built around clean browser contexts so one test does not leak into another. In Dify's suite, that principle maps to per-scenario session setup in `features/support/hooks.ts` and `DifyWorld`. +Playwright's model is built around clean browser contexts so one test does not leak into another. Apply it like this: - do not depend on another scenario having run first -- do not persist ad hoc scenario state outside `DifyWorld` -- do not couple ordinary scenarios to `@fresh` behavior -- when a flow needs special auth/session semantics, express that through the existing tag model or explicit hook changes +- keep scenario state in the runner's scenario-owned context rather than module globals +- model special authentication or session setup through explicit per-scenario fixtures rather than shared mutable state ### 2. Prefer user-facing locators Playwright recommends built-in locators that reflect what users perceive on the page. -Preferred order in this repository: +Preferred order: 1. `getByRole` 2. `getByLabel` @@ -79,16 +78,9 @@ Bad pattern: - stack arbitrary waits before every action - wait on unstable implementation details instead of the visible state the user cares about -### 5. Match debugging to the current suite +### 5. Match debugging to the active harness -Playwright's wider ecosystem supports traces and rich debugging tools. Dify's current suite already captures: - -- full-page screenshots -- page HTML -- console errors -- page errors - -Use the existing artifact flow by default. If a task is specifically about improving diagnostics, confirm the change fits the current Cucumber architecture before importing broader Playwright tooling. +Playwright supports traces, screenshots, page snapshots, and browser logs. Configure artifact capture at the runner boundary instead of adding parallel diagnostics to individual scenarios. ## Review Questions @@ -96,4 +88,4 @@ Use the existing artifact flow by default. If a task is specifically about impro - Is this assertion using Playwright's retrying semantics? - Is any explicit wait masking a real readiness problem? - Does this code preserve per-scenario isolation? -- Is a new abstraction really needed, or does it bypass the existing `DifyWorld` + step-definition model? +- Is a new abstraction really needed, or does it bypass the runner's scenario-owned context and lifecycle? diff --git a/.agents/skills/frontend-code-review/SKILL.md b/.agents/skills/frontend-code-review/SKILL.md index 85a8b1d9ef6..b5e262affc7 100644 --- a/.agents/skills/frontend-code-review/SKILL.md +++ b/.agents/skills/frontend-code-review/SKILL.md @@ -1,94 +1,48 @@ --- name: frontend-code-review -description: Review Dify frontend code for correctness, accessibility, component design, dify-ui usage, data/query boundaries, performance, and tests. Trigger for `.tsx`, `.ts`, `.js`, UI, React, Next.js, pending-change, or focused frontend review requests. +description: Use only when the user explicitly requests a review or audit of frontend code under `web/` or `packages/dify-ui/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for implementation-only requests, diagnosis without review intent, or backend-only code. --- # Frontend Code Review -## When To Use +Review the requested scope for concrete, reproducible regressions. This skill owns the review phase and routes directly to its bundled rule packs. For a combined review-and-fix request, establish findings before applying implementation or testing guidance. -Use this skill when the user asks to review, audit, analyze, or sanity-check frontend code under `web/`, `packages/dify-ui/`, or frontend-adjacent TypeScript files. +## Evidence First -Supported modes: +1. Establish the review scope from the requested files or current diff. +2. Read the changed lines, their behavior owner, and the nearest scoped `AGENTS.md`. +3. Trace public consumers, generated contracts, primitive APIs, or runtime configuration only when they decide correctness. +4. Report only findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk. -- **Pending-change review**: inspect staged and working-tree changes. -- **File-focused review**: inspect explicitly named files or paths. -- **Diff/snippet review**: review pasted diffs or snippets using best-effort references. +## Rule Routing -Do not use this skill for backend-only code under `api/`; use `backend-code-review` instead. +Read only the packs matched by the diff: -## Required Context +- DOM semantics, focus, keyboard, forms, disabled state, or visible interaction: [`references/accessibility-ui.md`][accessibility] +- Dify UI imports, Base UI wrappers, overlays, tokens, or primitive contracts: [`references/dify-ui.md`][dify-ui] +- Component ownership, props, state, Effects, navigation, or module boundaries: [`references/component-architecture.md`][component-architecture] +- Generated clients, Query, mutations, auth, SSR, URL state, or persistence: [`references/data-query-contracts.md`][data-query] +- Test files or a concrete missing-regression-test finding: [`references/testing.md`][testing] +- Bundle, waterfall, rendering, or subscription cost supported by evidence: [`references/performance.md`][performance] +- Stable Dify runtime invariants in the named paths: [`references/dify-invariants.md`][dify-invariants] +- General TypeScript or styling quality not owned above: [`references/code-quality.md`][code-quality] -Before reviewing, read the relevant local contracts: +Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `web/docs/overlay.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior. -- `web/AGENTS.md` for Dify frontend workflow, overlays, design tokens, state, and tests. -- `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md` when code uses or changes `@langgenius/dify-ui/*`. -- `web/docs/overlay.md` when reviewing dialogs, drawers, popovers, tooltips, menus, selects, comboboxes, or other floating UI. -- `web/docs/test.md` and the `frontend-testing` skill when reviewing tests or testability. -- `karpathy-guidelines` for scope control and focused, verifiable changes. -- `how-to-write-component` when reviewing React component structure, ownership, effects, query/mutation contracts, or memoization. +## Severity And Output -For any UI, UX, or accessibility review, fetch the latest Web Interface Guidelines before finalizing findings. Treat them as a required baseline, not the complete source of accessibility truth: +- **P0**: security or privacy leak, data loss, production crash, or inaccessible critical workflow. +- **P1**: user-visible regression, invalid API or authorization contract, hydration failure, or broken primary interaction. +- **P2**: concrete maintainability, performance, test, or accessibility defect likely to cause incorrect behavior. +- **P3**: minor actionable cleanup; omit unless the user requested a thorough audit. -```text -https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md -``` +Lead with findings ordered by severity. Include a tight file and line reference, the failing contract or reproduction path, impact, and a concrete fix direction. If there are no findings, say `No issues found.` and state any material verification gap. Do not add praise sections, speculative risks, or an unsolicited offer to implement fixes. -If the review depends on a current framework, SDK, browser API, or accessibility behavior and local code does not settle it, check the current official docs first. For browser compatibility, deprecation, or behavior-sensitive frontend APIs, verify MDN or the relevant standard. - -## Rule Packs - -Apply every relevant rule pack: - -- [references/accessibility-ui.md](references/accessibility-ui.md) — accessibility, semantic HTML, focus, forms, keyboard, disabled states, copy, and long-content behavior. Combines Web Interface Guidelines with Dify UI, Base UI, MDN, and local primitive contracts. -- [references/dify-ui.md](references/dify-ui.md) — Dify UI primitive usage, Base UI semantics, overlays, forms, tokens, radius mapping, and primitive boundaries. -- [references/component-architecture.md](references/component-architecture.md) — component ownership, props, state, effects, exports, wrappers, and feature organization. -- [references/data-query-contracts.md](references/data-query-contracts.md) — generated contracts, TanStack Query, mutations, workspace/auth/SSR boundaries, URL/local storage state. -- [references/performance.md](references/performance.md) — React/Next performance review rules from Vercel guidance, scoped to real risk. -- [references/testing.md](references/testing.md) — frontend test review rules. -- [references/dify-invariants.md](references/dify-invariants.md) — stable Dify-specific runtime invariants that generic React/a11y rules will not catch. -- [references/code-quality.md](references/code-quality.md) — general TypeScript, styling, naming, and maintainability rules. - -## Review Process - -1. Identify the review scope. For pending changes, inspect `git diff --stat`, `git diff`, and staged diff if relevant. For file-focused reviews, stay within the named files unless a referenced owner/contract must be read. -2. Read code around the changed lines and the owning module. Do not review by isolated snippets when nearby ownership, labels, query inputs, or overlay structure decide correctness. -3. Check user-visible regressions first: accessibility, broken interaction, auth/permission leaks, query/hydration errors, data loss, navigation mistakes, and impossible states. -4. Then check maintainability and performance: ownership, effects, wrappers, memoization, bundle/waterfall risks, tests, and design-system drift. -5. Report only actionable findings. Do not list speculative risks, style preferences, or broad refactors unless they are directly tied to a reproducible issue in scope. - -## Severity - -- **P0**: security/privacy/auth leak, data loss, production crash, inaccessible critical flow, or broken primary workflow. -- **P1**: user-visible regression, hydration/SSR failure, invalid API/query contract, broken keyboard/focus behavior, or serious design-system/a11y violation. -- **P2**: maintainability or performance issue likely to cause bugs, duplicated state, incorrect ownership, missing tests for risky behavior, or non-critical a11y issue. -- **P3**: minor cleanup with clear value. Omit unless the user asked for a thorough audit. - -## Output Format - -Lead with findings, ordered by severity. Use this structure: - -```markdown -## Findings - -- [P1] Short issue title - File: `path/to/file.tsx:123` - Why it matters and how to reproduce or reason about it. - Suggested fix: concrete fix direction. - -## Open Questions - -- Question or assumption, if any. - -## Summary - -Brief secondary context. Mention tests not run or residual risk. -``` - -Rules: - -- If there are no findings, say `No issues found.` and mention any test gaps or residual risk. -- Always include file and line when available. -- Keep findings concrete and reproducible. -- Do not include praise sections by default. -- Do not ask to apply fixes unless the user explicitly wants review plus implementation. +[accessibility]: references/accessibility-ui.md +[code-quality]: references/code-quality.md +[component-architecture]: references/component-architecture.md +[data-query]: references/data-query-contracts.md +[dify-invariants]: references/dify-invariants.md +[dify-ui]: references/dify-ui.md +[performance]: references/performance.md +[testing]: references/testing.md diff --git a/.agents/skills/frontend-code-review/references/component-architecture.md b/.agents/skills/frontend-code-review/references/component-architecture.md index 9f66533d215..ffabbf4cd69 100644 --- a/.agents/skills/frontend-code-review/references/component-architecture.md +++ b/.agents/skills/frontend-code-review/references/component-architecture.md @@ -46,14 +46,13 @@ When existing components already own interaction logic, prefer reusing or extend Flag: -- `React.FC` / `FC`. -- Default exports outside framework-required files. +- Declaration or export rewrites made only for stylistic uniformity, without changing an owned behavior or contract. - Named `Props` types for trivial one-off props where inline typing is clearer. - Props named by UI implementation instead of domain/API role. - API data converted too early or under a generic name that breaks traceability. - Callers duplicating fallback checks that the lowest rendering component already handles. -Prefer top-level `function` declarations for components and module helpers. Use arrow functions for callbacks and local lambdas. +Do not flag `FC`, `React.FC`, function declarations, arrow functions, named exports, or default exports by syntax alone. Report them only when the chosen form causes a concrete type, lifecycle, export, framework, or enforced package-contract defect. ## Effects diff --git a/.agents/skills/frontend-code-review/references/data-query-contracts.md b/.agents/skills/frontend-code-review/references/data-query-contracts.md index c1dadfafaf6..db2e2017d27 100644 --- a/.agents/skills/frontend-code-review/references/data-query-contracts.md +++ b/.agents/skills/frontend-code-review/references/data-query-contracts.md @@ -12,7 +12,7 @@ Flag: - Re-declaring API DTOs in components. - Adding compatibility layers instead of migrating the pointed line and deleting the old layer. -Use `web/contract/*` as the API shape source of truth. Follow existing `{ params, query?, body? }` input shape. +Backend Pydantic and OpenAPI schemas own API shape. Generated clients and schemas under `packages/contracts/generated/*` are authoritative at frontend boundaries and use the `{ params, query?, body? }` input shape. ## Queries diff --git a/.agents/skills/frontend-code-review/references/dify-ui.md b/.agents/skills/frontend-code-review/references/dify-ui.md index 27eced33b6a..93484a6a0fc 100644 --- a/.agents/skills/frontend-code-review/references/dify-ui.md +++ b/.agents/skills/frontend-code-review/references/dify-ui.md @@ -122,7 +122,7 @@ Flag: - Manual class strings that duplicate primitive variants. - `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping. -Use the Figma radius mapping from `packages/dify-ui/AGENTS.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`. +Use the Figma radius mapping from `packages/dify-ui/README.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`. Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state. diff --git a/.agents/skills/frontend-testing/SKILL.md b/.agents/skills/frontend-testing/SKILL.md index cb71dc14772..168a3906c16 100644 --- a/.agents/skills/frontend-testing/SKILL.md +++ b/.agents/skills/frontend-testing/SKILL.md @@ -1,35 +1,16 @@ --- name: frontend-testing -description: Write, update, or review Dify frontend tests using Vitest and Testing Library. Trigger for frontend specs, test coverage requests, regressions, testability, or testing strategy under web/ or packages/dify-ui/. +description: Use when writing or changing Vitest or React Testing Library tests under `web/` or `packages/dify-ui/`, or when the user explicitly requests frontend test strategy, including evaluation of an existing strategy. Do not use for frontend code-review-only requests, general testability discussion, Python tests, or Cucumber/Playwright E2E. --- -# Dify Frontend Testing +# Frontend Testing -Use this skill for Vitest work under `web/` and `packages/dify-ui/`. Do not use it for Python tests or Cucumber/Playwright tests under `e2e/`. +`web/docs/test.md` is the single policy owner. Read it before changing frontend tests; this skill adds no separate requirements. -## Required Source +1. Identify the observable contract and regression risk. +2. Choose the smallest boundary that includes the behavior owner. +3. Establish the failing case first when practical, then implement one coherent scenario. +4. Run the focused spec before the affected suite and relevant static checks. +5. Report the behavior verified and any remaining browser, visual, or end-to-end risk. -Before writing, changing, or reviewing frontend tests, read `web/docs/test.md` completely. It is the single source of truth. This skill provides an execution checklist and must not redefine or extend that policy. - -## Workflow - -1. Read the source, its behavior owner, nearby specs, and relevant public dependencies. -1. Apply the canonical guide to decide whether a test is needed and choose its boundary. -1. For a behavior change or bug fix, write or identify the failing scenario first when practical. -1. Implement one coherent scenario at a time and run the focused spec before expanding scope. -1. Finish with the affected suite and relevant repository checks. -1. Report what behavior was verified and any risk that still requires browser, visual, or end-to-end validation. - -When reviewing existing tests, recommend deleting low-value tests as readily as adding missing behavior coverage. - -Run focused tests from the owning workspace: - -```bash -# web/ -vp test run path/to/spec-or-directory - -# packages/dify-ui/ -vp test run --project unit src/path/to/spec -``` - -Run Dify UI Storybook tests with `vp test --project storybook --run`. Run broader checks only after the focused behavior passes. +Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use the `packages/dify-ui/README.md` Development section for Dify UI commands. diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index 3572d03e757..f9c0a2ccc64 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -1,144 +1,41 @@ --- name: how-to-write-component -description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states. +description: Use when implementing or refactoring React/TypeScript components and the task requires decisions about component ownership, feature boundaries, state, data flow, effects, or interaction ownership. Do not use for review-only requests, test-only work, copy-only edits, or styling-only changes. --- # How To Write A Component -Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch. +Use this skill to route component architecture decisions to its bundled references. Read only the references required by the current change. ## First Decisions -| Question | Default | Promote or extract only when | +| Question | Default | Promote only when | | --- | --- | --- | -| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. | -| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. | -| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. | -| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. | -| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. | -| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. | -| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. | -| Where should a hotkey live? | Keep a single-owner hotkey constant in its component. | Multiple production files share one command, or the feature owns a real command registry with shared metadata and behavior. | -| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. | +| Where should code live? | In the product workflow, route, or feature owner. | Several verticals need the same stable contract. | +| Who owns state and handlers? | The lowest visual owner that consumes them. | A parent coordinates one workflow or consistent snapshot. | +| Should state enter Jotai? | Keep component and form state local. | Siblings need one source of truth or scoped workflow persistence. | +| Who owns URL state? | Next.js route APIs and `nuqs`. | Atoms require a read-only route-identity bridge. | +| Who owns remote state? | TanStack Query at the lowest consumer. | Atom state drives the query or shared derivations consume it. | +| Is a wrapper needed? | Use the primitive or direct code. | The wrapper owns behavior, validation, state, or semantics. | +| Is an Effect needed? | Derive during render or handle the user action. | A named external system must be synchronized. | -## Core Defaults +## Topic Routing -- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit. -- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices. -- Preserve visible keyboard focus states on the final focusable element. Prefer styled `@langgenius/dify-ui/*` controls when available, because components such as `Button` and form/control primitives carry the standard Dify UI `focus-visible` styling. Do not assume every Dify UI export provides visual focus styles: headless anatomy parts and direct Base UI re-exports such as dialog/popover/tooltip/drawer triggers usually only provide behavior and semantics. When using native `button` / `a`, custom trigger `render` props, clickable rows, icon buttons, menu-like items, or direct trigger parts, verify the rendered focusable element has a visible focus state. If it does not, add the standard Dify UI focus style: `outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid`. Do not hide outlines without an equivalent visible `focus-visible` indicator. Component-specific focus styles should follow an existing styled primitive pattern or a concrete design constraint, not a new ad hoc style. -- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them. -- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README. -- Module README whitelist: `@/service/client`, `@/next/*`. -- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities. -- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly. -- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators. +- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership]. +- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state]. +- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data]. +- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. +- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime]. -## Layout And Ownership +## Workflow -- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions. -- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching. -- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains. -- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file. -- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache. -- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source. -- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state. -- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in Query and API flow. -- Do not replace prop drilling with one large view-model hook threaded through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it. -- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child, menu, or row own the action. +1. Identify the behavior owner and the public contract being changed. +2. Read the nearby implementation, tests, and only the routed skill references. +3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them. +4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and the `packages/dify-ui/README.md` Development section for Dify UI. -## Feature-Scoped Jotai - -- A Jotai-backed feature has one feature-local state file for shared primitive atoms, query atoms, derived atoms, write-only actions, mutation atoms, submission orchestration, provider exports, and optional scope configuration. -- Keep component-owned synchronous UI state local even inside Jotai features: dialog open flags, menus/popovers, confirmations, field drafts, and selected local options usually belong in component state. -- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts. -- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit. -- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms. -- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props. -- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly. -- `jotai-tanstack-query` query atoms do not support TanStack Query tracked properties. A component that reads `useAtomValue(queryAtom)` subscribes to the whole query result, even if it only accesses `data`, `isLoading`, or `isError`. Export field-specific derived atoms and have components read the exact fields they render; use `selectAtom(queryAtom, result => result.field)` for query-result fields so unchanged selections do not notify subscribers. Keep direct `useAtomValue(queryAtom)` only when the component or hook genuinely needs the full observer result. -- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics. -- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface. -- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy(() => { throw new Error(...) })` when consumers should see a non-null type. -- Order state files by dependency graph: types/constants, primitives, query atoms, query-data derived atoms, business/readiness derived atoms, write actions, mutation atoms, submission orchestration, provider exports. -- Name derived atoms as business facts and write atoms as user or workflow commands. Components should read or write the exact atom they need with `useAtomValue` or `useSetAtom`. -- Menu/dialog `open` state usually stays local, but a scoped atom is acceptable when a composed menu plus secondary surface would otherwise pass confusing `open`/`onClose` props through unrelated layers. Scope that primitive with the surface instance so reset behavior stays local. -- Keep independent dialog lifecycles separate. Avoid one discriminated "current action dialog" atom when dialogs have separate open state, loading guards, or reset behavior. - -## Components, Props, And Types - -- Type component signatures directly; do not use `FC` or `React.FC`. -- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs. -- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files. -- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files. -- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer. -- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them. -- Preserve domain value types for selection components. Do not widen enum, union, boolean, numeric, object, or nullable select/radio values to `string`; keep wrappers and option value carriers typed from their feature option collection. -- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role. -- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts. -- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary. -- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data. - -## Keyboard Shortcuts - -- Distinguish application commands from local keyboard semantics before choosing an API. Use `@tanstack/react-hotkeys` for application commands. Keep menu navigation, dialog Escape handling owned by a primitive, editor commands, and other widget-scoped ARIA interactions in their local component or primitive. -- Use `useHotkey` or `useHotkeys` for registered commands. For a command intentionally owned by an existing `onKeyDown`, use `matchesKeyboardEvent` instead of hand-written `metaKey` / `ctrlKey` parsing or a second global listener. -- Define a reusable string command with `satisfies Hotkey` and an object-form command with `satisfies RawHotkey`. Reserve `RegisterableHotkey` for API boundaries that intentionally accept either form. A one-time inline literal passed directly to TanStack is already type-checked; extract it when registration, display, metadata, or another production consumer needs the same source. -- Keep registered hotkeys distinct from held keys and display-only accelerators. Use `IndividualKey` with `useKeyHold` for held-key interactions, and use an explicitly named `displayKey` for local widget accelerators that are not registered `Hotkey` values. -- Keep registration and keycap/menu display derived from one canonical command. Do not maintain a hotkey string beside a separate `['Mod', ...]` display array. -- Keep a single-owner command constant in its owning component. Create a feature-local `hotkeys.ts` only when multiple production files consume the same command. Keep a dedicated definitions/registry module when a feature owns a real command system with IDs, metadata, alternate bindings, and centralized registration. Tests do not count as another production owner, and file-name uniformity alone is not a reason to extract. -- Make scope and availability explicit. Use `enabled` for business or surface lifecycle, `ignoreInputs` for whether input-like elements may trigger the command, and `target` when the command belongs to a concrete DOM subtree. Global application commands may use the document target; inline editors and composed overlays should prefer the actual editor or Base UI Popup ref when that owner is exposed. -- Put a scoped ref on the real behavior owner. Do not add a wrapper DOM element solely to obtain a hotkey target. If a shared overlay convenience component hides the Popup ref, either rely on its modal lifecycle/focus boundary when that is sufficient or design the primitive API separately; do not create a fake owner at the call site. -- Set `preventDefault` and `stopPropagation` according to the existing product behavior and browser interaction. Do not silently accept TanStack defaults when migrating from another listener if that changes typing, submission, or propagation semantics. -- Test observable command behavior, disabled/input/target scope, and the shared registration/display contract at the owning feature boundary. Prefer partial mocks that retain TanStack formatting and matching behavior when a registration boundary must be isolated. - -## Generated API And Nullable Data - -- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`. -- Do not hand-write DTO mirrors, widen generated fields/enums, or add parallel frontend enum/status layers unless they model product state not represented by the API. -- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Presentation-only tone maps should be keyed by generated enums. -- Normalize or coerce only at real boundaries: user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. -- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `null` or `undefined` until the final boundary requiring a string. -- Do not use `value || undefined` for mutation fields where `''` means "clear this value". Trim or normalize at the form boundary, then preserve intentional empty strings. -- Prefer nullable-tolerant render props for API-returned rows. Narrow only where a real value is required, such as mutation params, route hrefs, select values, query input, or required React keys. -- Build required values in the same branch that proves them, using `flatMap`, a local loop, or an early return. Avoid truthiness guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after filters. -- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by narrowing filters. -- Empty collection fallbacks are for not-yet-loaded query data or genuinely nullable collections at the owning render boundary, not for hiding required API fields. - -## Queries And Mutations - -- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape. -- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`. -- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`. -- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms. -- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object. -- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`. -- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options. -- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`. -- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior. -- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior. -- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally. -- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache. -- Do not use deprecated `useInvalid` or `useReset`. -- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`. - -## Boundaries And Overlays - -- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner. -- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner. -- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary. -- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent. -- Preserve composability by separating behavior ownership from placement ownership: an action can own trigger/open/menu content while the caller owns slots, offsets, and alignment. -- When a dialog, dropdown, or popover accepts controlled `open`, mount it unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset instead of `{open && }` wrappers. -- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu command open the dialog, and mount the dialog outside menu popup content. -- For dialogs and alert dialogs, keep the root responsible for `open` wiring and put query/mutation hooks inside the content component when work should mount only after the overlay opens. -- Prefer uncontrolled overlay roots when the library can own open state. Use `onOpenChange` for side effects and CSS/data selectors for open-state styling. -- Avoid wrapper DOM unless it provides layout, semantics, accessibility, state ownership, or library integration. Avoid shallow wrappers, hook-to-props adapters, layout-only render props, children pass-through wrappers, and prop renaming unless they add real behavior or a real boundary. - -## Effects, Navigation, And Performance - -- Use Effects only to synchronize with external systems. Do not use Effects to transform props/state for rendering, handle user actions, copy state, reset state from props, or fetch data. -- For forms initialized from query data, prefer keyed remounts or surface-entry atom hydration over Effects that copy query data into form state. -- Prefer framework data APIs or TanStack Query for data fetching. -- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission. -- Before using `memo`, move changing state down to the smallest component that uses it. If state must wrap stable content, lift the stable content up and pass it as `children`. -- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason. +[data]: references/data.md +[interactions]: references/interactions.md +[ownership]: references/ownership.md +[runtime]: references/runtime.md +[state]: references/state.md diff --git a/.agents/skills/how-to-write-component/references/data.md b/.agents/skills/how-to-write-component/references/data.md new file mode 100644 index 00000000000..e151c2c0b5b --- /dev/null +++ b/.agents/skills/how-to-write-component/references/data.md @@ -0,0 +1,42 @@ +# Component Data And Queries + +Read this document when a component consumes generated contracts, nullable API values, TanStack Query, mutations, prefetching, authentication, or workspace state. + +## Generated Contracts + +- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. Enterprise APIs use `packages/contracts/generated/enterprise/*`. +- Backend Pydantic and OpenAPI schemas own API shape. Follow the generated `{ params, query?, body? }` input shape; when it is wrong, fix the backend schema and regenerate `packages/contracts/generated/*`. +- Do not hand-write DTO mirrors, widen generated fields or enums, edit generated output, or add a parallel frontend status layer unless it models product state absent from the API. +- Check deprecated markers, schema shape, and the actual consumer before assuming that a generated operation is ready to use. +- Normalize only at real boundaries such as user input, search, URL params, filenames, DOM IDs, or a required legacy adapter. +- Preserve `null`, `undefined`, and intentional empty strings until the final boundary. Do not use `value || undefined` when an empty string means clearing a field. +- Build required values in the branch that proves them. Avoid `filter(Boolean)`, truthiness filters, non-null assertions after filters, and placeholder values used only to satisfy types. + +## Queries + +- Use generated options directly with `useQuery(consoleQuery.xxx.queryOptions(...))`, `marketplaceQuery`, or the equivalent generated client. +- If query input comes from atom state, keep it in `atomWithQuery`; do not unwrap the atom in a component solely to call `useQuery`. +- For missing required input, branch the whole generated input with `skipToken`. Add `enabled` only for an independent execution condition; do not put `skipToken` inside a placeholder payload or coerce IDs to empty strings. +- Return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly from TanStack Query atoms. Pass supported options into the generated call instead of spreading into a parallel object. +- Share the exact options between prefetch and render when they represent the same request. Do not extract option helpers merely to reuse input construction. +- Avoid pass-through service hooks that only rename generated options. Keep feature hooks for actual orchestration or shared domain behavior. + +## Mutations And Cache + +- Use generated `mutationOptions()` directly for owner-local mutations. +- Put shared invalidation, retries, and cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Local callbacks may own toast, close, and navigation effects but must not replace shared cache policy. +- Prefer `mutate(...)`. Use `mutateAsync(...)` only when Promise composition is required, and catch awaited failures. +- Preserve intentional empty values and current list/detail ownership when updating data. Do not add optimistic updates without a verified owner contract. + +## Prefetch And Hidden Surfaces + +- Prefetch expensive secondary content from the trigger or menu-open event when it benefits the visible path. Do not mount hidden subscribers solely to warm the cache. +- `prefetchQuery` is cache warmup, not an authorization or availability gate. Use a hard fetch boundary when the server must decide whether rendering may proceed. + +## SSR, Authentication, And Workspace + +- Static configuration owns path-invariant routing. Request-dependent authentication, setup, role, and tenant decisions belong to SSR or runtime decision boundaries. +- Distinguish soft SSR cache warming from authoritative decisions. Prefetched or placeholder data must not grant access or represent successful availability. +- Never reuse tenant-scoped state after switching workspaces. Discard it at the switch boundary or isolate it by workspace identity. +- Do not make product or authorization decisions from bootstrap defaults. Wait for authoritative data, or render an explicit loading or error state. +- Keep loading and Suspense behavior inside the feature that owns the request. Do not add fake global data merely to bypass that boundary. diff --git a/.agents/skills/how-to-write-component/references/interactions.md b/.agents/skills/how-to-write-component/references/interactions.md new file mode 100644 index 00000000000..a89f2b88633 --- /dev/null +++ b/.agents/skills/how-to-write-component/references/interactions.md @@ -0,0 +1,31 @@ +# Component Interactions And Overlays + +Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay guide]. + +## Focus And Semantics + +- Preserve a visible focus indicator on the final focusable element. Styled Dify UI controls usually provide it; headless anatomy parts and direct trigger exports may not. +- Native buttons, links, custom trigger renderers, clickable rows, icon controls, and menu-like items must retain their correct native semantics and accessible name. +- Do not hide an outline without an equivalent visible `focus-visible` treatment. Follow an existing Dify UI pattern rather than inventing a call-site style. + +## Keyboard Commands + +- Distinguish application commands from widget-local keyboard semantics. Use `@tanstack/react-hotkeys` for application commands; keep menu navigation, dialog Escape handling, editor behavior, and ARIA widget keys in their local primitive or owner. +- Use `useHotkey` or `useHotkeys` for registered commands. When an existing `onKeyDown` intentionally owns the command, use `matchesKeyboardEvent` rather than duplicating modifier parsing or adding another global listener. +- Keep registration and keycap or menu display derived from one canonical command. Distinguish registered commands, held keys, and display-only accelerators. +- Keep a single-owner command beside its component. Create a feature-local hotkey module only when several production files share it; tests alone do not justify extraction. +- Make availability and scope explicit with `enabled`, `ignoreInputs`, and `target`. Put a target ref on the actual behavior owner rather than creating wrapper DOM solely for hotkey scope. +- Preserve existing `preventDefault` and propagation behavior when migrating command APIs. +- Test observable command behavior, disabled and input scope, target scope, and the registration/display contract at the owning feature boundary. + +## Secondary Surfaces + +- Follow `web/docs/overlay.md` for primitive choice. Dify UI primitives are the default, with package-approved Web wrappers such as `Infotip` where the overlay guide allows them. +- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment. +- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content. +- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers. +- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening. +- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior. +- Do not add manual portals or call-site z-index escalation. Fix ownership and stacking structure at the shared boundary. + +[overlay guide]: ../../../../web/docs/overlay.md diff --git a/.agents/skills/how-to-write-component/references/ownership.md b/.agents/skills/how-to-write-component/references/ownership.md new file mode 100644 index 00000000000..d8da521aafc --- /dev/null +++ b/.agents/skills/how-to-write-component/references/ownership.md @@ -0,0 +1,39 @@ +# Component Ownership And Modules + +Read this document when adding, moving, splitting, or refactoring React components or feature modules. + +## Vertical Modules + +- Organize code by product workflow, route, or behavior owner. Keep components, hooks, local types, atoms, query helpers, tests, and small utilities beside the code that changes with them. +- Name page and tab folders after the current route, tab, or user-visible surface. Do not preserve stale parent groupings that no longer own multiple surfaces. +- Split a growing page or tab by product or visual owners. Keep the feature root for its public entrypoint and genuinely cross-owner coordination. +- Import other features only through explicit public entrypoints. Avoid barrels that merely re-export secondary owners. +- Promote code outside a feature only when multiple verticals use the same stable contract. Possible future reuse is not sufficient. + +## Component Ownership + +- Put state, data access, loading, empty, error, and handlers in the lowest visual owner that uses them. +- Keep coordination in a parent only when it needs one consistent snapshot or coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors. +- Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests. +- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept. +- One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state. +- Do not replace prop drilling with one large view-model hook. Move each query, derived value, and handler to the concrete owner that consumes it. +- Keep source selection, defaults, validation, dirty checks, and payload shaping beside the workflow that owns submission. + +## Boundaries + +- State-heavy wizards, drawers, modals, and secondary workflows can form a small vertical surface with an entrypoint, optional feature-local state, and shallow owners matching real visual regions. +- The entrypoint owns route integration, provider wiring, close behavior, and mounting. Composition owners handle workflow branches; the closest visual owner handles section branches. +- Separate hidden dialogs, dropdowns, and popovers into small local owners when their content obscures the parent flow. +- Keep cohesive forms, menu bodies, and one-off helpers local unless they have their own state, reuse, or semantic boundary. +- Avoid wrapper components and wrapper DOM that only rename props, pass children through, or hide the real primitive. A wrapper must own behavior, validation, state, accessibility, layout, or library integration. +- Loading states for page sections, cards, lists, tables, forms, and drawers should use skeletons scoped to the loaded content. Reserve spinners for small inline busy indicators. + +## Components And Types + +- Choose component declaration and export forms from the actual component contract, framework requirements, and enforced package rules. Existing style is context, not authority; do not rewrite unaffected code solely to normalize `FC`, `function`, arrow-function, named-export, or default-export forms. +- Type simple one-off props inline. Name a `Props` type when it is reused, exported, complex, or materially clearer. +- Use API-generated or API-returned types at component boundaries. Keep one-off UI refinements and conversions beside their owner. +- Preserve domain value types for selections. Do not widen enums, unions, booleans, numbers, objects, or nullable values to `string` before a real boundary requires it. +- Avoid generic `common.tsx` buckets and aliases that only rename another type. Name files, values, and public types after their domain role. +- Put fallback and invariant checks in the lowest component that already renders that state. Do not extract helpers whose only purpose is hiding missing display data. diff --git a/.agents/skills/how-to-write-component/references/runtime.md b/.agents/skills/how-to-write-component/references/runtime.md new file mode 100644 index 00000000000..c2b5a667355 --- /dev/null +++ b/.agents/skills/how-to-write-component/references/runtime.md @@ -0,0 +1,23 @@ +# Component Effects, Navigation, And Runtime Cost + +Read this document when a change introduces Effects, navigation side effects, memoization, preloading, or render-cost optimizations. + +## Effects + +- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API. +- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query. +- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects. + +## Navigation + +- Use `Link` for ordinary navigation. +- Use router APIs for command-flow side effects such as mutation success, guarded redirects, or form submission. +- Keep shareable navigation state in the URL rather than hidden component state. + +## Runtime Cost + +- Move changing state to the smallest consumer before considering memoization. Stable parent content can be lifted and passed as children. +- Avoid `memo`, `useMemo`, and `useCallback` unless identity or computation has a demonstrated consumer or measurable cost. +- Start independent remote work together and await it near the branch that consumes it. Avoid introducing request waterfalls. +- Load heavy optional surfaces on demand when they sit behind a dialog, tab, command, or feature activation. +- Use narrow selectors or field-level atoms for broad stores and subscriptions. Do not optimize simple primitive expressions merely for stylistic consistency. diff --git a/.agents/skills/how-to-write-component/references/state.md b/.agents/skills/how-to-write-component/references/state.md new file mode 100644 index 00000000000..0ede0a85e92 --- /dev/null +++ b/.agents/skills/how-to-write-component/references/state.md @@ -0,0 +1,38 @@ +# Component State And URL Ownership + +Read this document when a change involves Jotai, form drafts, route identity, shared client state, or local persistence. + +## Choose The Owner + +- Keep synchronous state local when one component owns it: dialog and menu state, confirmations, field drafts, and local selections usually belong to the component or DOM. +- Use feature-scoped Jotai when siblings need one source of truth, values drive other atoms, or a scoped workflow must preserve state across hidden or unmounted steps. +- Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels. +- Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage. + +## Forms + +- Prefer uncontrolled Dify UI form and field controls when values are only read at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts. +- Promote form values to atoms only when another owner reacts to in-progress values, the draft must survive scoped unmounting, or several workflow steps edit the same draft. +- Keep validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission. + +## Route And URL State + +- Treat `useParams`, route arguments, and `nuqs` as the owners of URL identity and updates. +- Hydrate a primitive atom at the route or surface boundary only when query atoms or shared derived atoms require route identity. Keep URL writes in route and query-state APIs. +- Within one route-owned feature, choose one route-identity source. Do not hydrate route identity into atoms while also threading the same ID through multiple component layers. +- Put shareable filters, tabs, pagination, and search state in the URL. Keep one-shot navigation signals and transient UI state out of persistent subscriptions. + +## Jotai And Query + +- A Jotai-backed feature may keep one feature-local state module ordered by dependency: types and constants, primitives, query atoms, query-data derivations, business facts, commands, mutations, submission orchestration, and provider exports. +- Use `atomWithQuery` or `atomWithMutation` for async work driven by atom state. Do not hand-roll loading, error, or in-flight state for atom-orchestrated work. +- Use field-specific derived atoms for query results. `jotai-tanstack-query` does not provide TanStack Query tracked properties, so reading a whole query atom subscribes to the entire observer result. +- Leave query and mutation atoms unscoped so they retain the shared QueryClient cache. Scope resettable primitives and hydration tuples; scope a derived atom only when all dependencies should be private to the surface. +- Use non-null lazy primitives for values always hydrated by a scope provider. Name derived atoms as business facts and write atoms as user or workflow commands. +- Keep independent dialog lifecycles separate. A scoped open-state atom is acceptable only when composed sibling surfaces would otherwise pass confusing lifecycle props through unrelated owners. + +## Persistence + +- Use feature-owned storage modules built on `createLocalStorageState`; callers should not scatter direct storage access or raw keys. +- Persist high-frequency interaction state only on commit or after updates settle. +- Do not add ad hoc global event listeners for shared state. Centralize subscriptions through the owning atom, store, or subscription hook. diff --git a/.agents/skills/karpathy-guidelines/SKILL.md b/.agents/skills/karpathy-guidelines/SKILL.md deleted file mode 100644 index 2b7330f5b83..00000000000 --- a/.agents/skills/karpathy-guidelines/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: karpathy-guidelines -description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work. ---- - -# Karpathy Guidelines - -Use this skill whenever you touch code in this repository. - -## Principles - -- Keep the change small and directly tied to the user request. -- Prefer the simplest implementation that fits the existing codebase. -- Read the nearby code first, then match its patterns. -- Avoid unrelated refactors, broad rewrites, or style churn. -- Preserve existing behavior unless the user explicitly asked to change it. -- Treat regressions as a signal to narrow the change, not to add workaround layers. - -## Workflow - -1. Inspect the current implementation and tests around the change. -2. Make the smallest coherent edit. -3. Add or update focused tests when the behavior changes or the risk is non-trivial. -4. Run the narrowest relevant verification first. -5. Report exactly what was verified and anything left unverified. - -## Review Checklist - -- Does this change solve the stated problem without expanding scope? -- Did it preserve existing route/component/data-flow semantics? -- Are new abstractions justified by real complexity? -- Are tests focused on the behavior that could regress? -- Are unrelated files and generated artifacts left alone? diff --git a/.claude/skills/component-refactoring b/.claude/skills/component-refactoring deleted file mode 120000 index 53ae67e2f2e..00000000000 --- a/.claude/skills/component-refactoring +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/component-refactoring \ No newline at end of file diff --git a/.claude/skills/karpathy-guidelines b/.claude/skills/karpathy-guidelines deleted file mode 120000 index 743bef5277d..00000000000 --- a/.claude/skills/karpathy-guidelines +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/karpathy-guidelines \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0da1d9e9eb6..046c2e1dcec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,46 +1,8 @@ # AGENTS.md -## Project Overview +Dify is an open-source platform for building LLM applications, agentic workflows, and RAG pipelines. This monorepo contains the backend API (`api/`), frontend application (`web/`), deployment assets (`docker/`), standalone agent backend (`dify-agent/`), CLI (`cli/`), and end-to-end suite (`e2e/`). Follow the nearest scoped `AGENTS.md` for the files being changed. -Dify is an open-source platform for developing LLM applications with an intuitive interface combining agentic AI workflows, RAG pipelines, agent capabilities, and model management. +## Repository Gotchas -The codebase is split into: - -- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design -- **Frontend Web** (`/web`): Next.js application using TypeScript and React -- **Docker deployment** (`/docker`): Containerized deployment configurations -- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent - -## Backend Workflow - -- Read `api/AGENTS.md` for details -- Run backend CLI commands through `uv run --project api `. -- Integration tests are CI-only and are not expected to run in the local environment. - -## Frontend Workflow - -- Read `web/AGENTS.md` for details - -## Testing & Quality Practices - -- Follow TDD: red → green → refactor. -- Use `pytest` for backend tests with Arrange-Act-Assert structure. -- Enforce strong typing; avoid `Any` and prefer explicit type annotations. -- Write self-documenting code; only add comments that explain intent. - -## Language Style - -- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation. -- **TypeScript**: Use the strict config, run `pnpm check` for formatting, Oxlint, ESLint non-code checks, and type checking, and avoid `any` types. - -## General Practices - -- Prefer editing existing files; add new documentation only when requested. -- Inject dependencies through constructors and preserve clean architecture boundaries. -- Handle errors with domain-specific exceptions at the correct layer. - -## Project Conventions - -- Backend architecture adheres to DDD and Clean Architecture principles. -- Async work runs through Celery with Redis as the broker. -- Frontend user-facing strings must use `web/i18n/en-US/`; avoid hardcoded text. +- Run backend commands through `uv run --project api `. +- Backend integration tests are CI-only and are not expected to run locally. diff --git a/api/AGENTS.md b/api/AGENTS.md index 474da7800b2..ac77301f8dd 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -1,232 +1,25 @@ # API Agent Guide -## Notes for Agent (must-check) +Read surrounding module, class, and function docstrings plus non-obvious comments before changing backend behavior. They are local contracts; update them only when their owned behavior changes, and keep them aligned with the current code. -Before changing any backend code under `api/`, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec. +## Commands -Look for: +Run backend checks from the repository root: -- The module (file) docstring at the top of a source code file -- Docstrings on classes and functions/methods -- Paragraph/block comments for non-obvious logic - -### What to write where - -- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse. -- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing. - - Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard. - - Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes. -- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used). - - If the class is intentionally stateful, note what state exists and what methods mutate it. - - If concurrency/async assumptions matter, state them explicitly. -- **Function/method docstring**: behavioural contract. - - Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions. - - Add examples only when they prevent misuse. -- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states. - - Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality. - -### Rules (must follow) - -In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments. - -- **Before working** - - Read the notes in the area you’ll touch; treat them as part of the spec. - - If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality. - - If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour). -- **During working** - - Keep the notes in sync as you discover constraints, make decisions, or change approach. - - If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants. - - Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct. - - Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions. -- **When finishing** - - Update the notes to reflect what changed, why, and any new edge cases/tests. - - Remove or rewrite any comments that could be mistaken as current guidance but no longer apply. - - Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery. - -## Coding Style - -This is the default standard for backend code in this repo. Follow it for new code and use it as the checklist when reviewing changes. - -### Linting & Formatting - -- Use Ruff for formatting and linting (follow `.ruff.toml`). -- Keep each line under 120 characters (including spaces). - -### Naming Conventions - -- Use `snake_case` for variables and functions. -- Use `PascalCase` for classes. -- Use `UPPER_CASE` for constants. - -### Typing & Class Layout - -- Code should usually include type annotations that match the repo’s current Python version (avoid untyped public APIs and “mystery” values). -- Prefer modern typing forms (e.g. `list[str]`, `dict[str, int]`) and avoid `Any` unless there’s a strong reason. -- For dictionary-like data with known keys and value types, prefer `TypedDict` over `dict[...]` or `Mapping[...]`. -- For optional keys in typed payloads, use `NotRequired[...]` (or `total=False` when most fields are optional). -- Keep `dict[...]` / `Mapping[...]` for truly dynamic key spaces where the key set is unknown. - -```python -from datetime import datetime -from typing import NotRequired, TypedDict - - -class UserProfile(TypedDict): - user_id: str - email: str - created_at: datetime - nickname: NotRequired[str] -``` - -- For classes, declare all member variables explicitly with types at the top of the class body (before `__init__`), even when the class is not a dataclass or Pydantic model, so the class shape is obvious at a glance: - -```python -from datetime import datetime - - -class Example: - user_id: str - created_at: datetime - - def __init__(self, user_id: str, created_at: datetime) -> None: - self.user_id = user_id - self.created_at = created_at -``` - -### General Rules - -- Use Pydantic v2 conventions. -- Use `uv` for Python package management in this repo (usually with `--project api`). -- Prefer simple functions over small “utility classes” for lightweight helpers. -- Avoid implementing dunder methods unless it’s clearly needed and matches existing patterns. -- Never start long-running services as part of agent work (`uv run app.py`, `flask run`, etc.); running tests is allowed. -- Keep files below ~800 lines; split when necessary. -- Keep code readable and explicit—avoid clever hacks. - -### Architecture & Boundaries - -- Mirror the layered architecture: controller → service → core/domain. -- Reuse existing helpers in `core/`, `services/`, and `libs/` before creating new abstractions. -- Optimise for observability: deterministic control flow, clear logging, actionable errors. - -### Owner-Bound Resource References - -- Resolve and validate the outer owner before binding a nested resource ID. -- For stable single-parent chains, use immutable nested `NamedTuple` refs. -- Root refs carry tenant plus root ID; child refs carry the parent ref. -- In production, construct refs through the domain ref service. -- Python allowing direct construction does not grant authorization. -- Scope every consuming query with complete owner predicates; refs are not security tokens. -- Keep polymorphic owners flat until explicit nominal owner types exist. -- Do not add generic ref bases or compatibility fields only for uniformity. -- Reconstruct internal refs from validated database state after payload or async boundaries. - -### Logging & Errors - -- Never use `print`; use a module-level logger: - - `logger = logging.getLogger(__name__)` -- Include tenant/app/workflow identifiers in log context when relevant. -- Raise domain-specific exceptions (`services/errors`, `core/errors`) and translate them into HTTP responses in controllers. -- Log retryable events at `warning`, terminal failures at `error`. - -### SQLAlchemy Patterns - -- Models inherit from `models.base.TypeBase`; do not create ad-hoc metadata or engines. -- Open sessions with context managers: - -```python -from sqlalchemy.orm import Session - -with Session(db.engine, expire_on_commit=False) as session: - stmt = select(Workflow).where( - Workflow.id == workflow_id, - Workflow.tenant_id == tenant_id, - ) - workflow = session.execute(stmt).scalar_one_or_none() -``` - -- Prefer SQLAlchemy expressions; avoid raw SQL unless necessary. -- Always scope queries by `tenant_id` and protect write paths with safeguards (`FOR UPDATE`, row counts, etc.). -- Introduce repository abstractions only for very large tables (e.g., workflow executions) or when alternative storage strategies are required. - -### Storage & External I/O - -- Access storage via `extensions.ext_storage.storage`. -- Use `core.helper.ssrf_proxy` for outbound HTTP fetches. -- Background tasks that touch storage must be idempotent, and should log relevant object identifiers. - -### Pydantic Usage - -- Define DTOs with Pydantic v2 models and forbid extras by default. -- Use `@field_validator` / `@model_validator` for domain rules. - -Example: - -```python -from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator - - -class TriggerConfig(BaseModel): - endpoint: HttpUrl - secret: str - - model_config = ConfigDict(extra="forbid") - - @field_validator("secret") - def ensure_secret_prefix(cls, value: str) -> str: - if not value.startswith("dify_"): - raise ValueError("secret must start with dify_") - return value -``` - -### Generics & Protocols - -- Use `typing.Protocol` to define behavioural contracts (e.g., cache interfaces). -- Apply generics (`TypeVar`, `Generic`) for reusable utilities like caches or providers. -- Validate dynamic inputs at runtime when generics cannot enforce safety alone. - -### Tooling & Checks - -Quick checks while iterating: - -- Format: `make format` -- Lint (includes auto-fix): `make lint` +- Format and lint: `make lint` - Type check: `make type-check` - Unit tests: `make test` -- Full backend tests, including Docker-backed suites: `make test-all` -- Targeted tests: `make test TARGET_TESTS=./api/tests/` +- Targeted tests: `make test TARGET_TESTS=./api/tests/` -Before opening a PR / submitting: +Run direct Python commands through `uv run --project api`. Docker-backed integration suites are normally CI-owned. Do not start long-running services as part of routine agent work. -- `make lint` -- `make type-check` -- `make test` +## Architecture And Boundaries -### Controllers & Services - -- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic. -- Services: coordinate repositories, providers, background tasks; keep side effects explicit. -- Document non-obvious behaviour with concise docstrings and comments. -- For `204 No Content` responses, return an empty body only; never return a dict, model, or other payload. -- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`. - In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response - DTOs with `register_response_schema_models(...)`, serialize response DTOs with `dump_response(...)`, - and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns. - -### System Features Contract - -- Treat the shared Console/Web `/system-features` response as a minimal unauthenticated bootstrap allowlist, not a - general configuration or feature-discovery endpoint. Existing fields do not establish precedent. -- Before adding a field, read `controllers/API_SCHEMA_GUIDE.md#public-system-features-contract` and provide evidence - that both Console and Web have production consumers that require it before authentication. -- Never place backend-only policy, surface-specific configuration, post-authentication state, speculative values, or - large/slow payloads in `SystemFeatureModel`. Use the consumer or domain owner described in the schema guide. -- Agents and reviewers must reject additions whose owner, public exposure, pre-authentication need, or root SSR cost - is not explicit. - -### Miscellaneous - -- Use `configs.dify_config` for configuration—never read environment variables directly. -- Maintain tenant awareness end-to-end; `tenant_id` must flow through every layer touching shared resources. -- Queue async work through `services/async_workflow_service`; implement tasks under `tasks/` with explicit queue selection. -- Keep experimental scripts under `dev/`; do not ship them in production builds. +- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in `core/` or its domain owner. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions. +- Before changing controller schemas, generated API contracts, or `SystemFeatureModel`, read `controllers/API_SCHEMA_GUIDE.md`. Treat `/system-features` as a minimal unauthenticated bootstrap allowlist, not a general configuration registry. +- Scope tenant-owned reads and writes by the complete owner chain, and propagate `tenant_id` across every affected layer. Reconstruct trusted internal references from validated database state after payload or async boundaries. +- Keep write transactions explicit and bounded. Do not perform external I/O inside an open transaction unless a documented consistency contract requires it. +- Read configuration through `configs.dify_config`, access storage through `extensions.ext_storage.storage`, and route outbound HTTP through the existing SSRF-safe owner in `core.helper.ssrf_proxy`. +- Use Pydantic v2 for request and response models. Reuse domain-specific exceptions and translate them at the controller boundary. +- Use existing Celery task and queue owners for asynchronous work; do not route unrelated jobs through workflow-specific services. +- Celery tasks that may be retried or redelivered must keep side effects idempotent and log affected resource identifiers. diff --git a/api/controllers/API_SCHEMA_GUIDE.md b/api/controllers/API_SCHEMA_GUIDE.md index a1e412a3630..30e6ddc0a8d 100644 --- a/api/controllers/API_SCHEMA_GUIDE.md +++ b/api/controllers/API_SCHEMA_GUIDE.md @@ -162,6 +162,9 @@ That documents a GET request body and is not the expected contract. ## Responses +`204 No Content` responses must not serialize a response body. Return the status using the established controller pattern; +do not return a dictionary, response model, or other payload. + Response models should inherit from `ResponseModel`: ```python diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 56a59128c03..446c85abdcc 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -1,99 +1,26 @@ # AGENTS.md — difyctl (TypeScript CLI) -TypeScript port of difyctl. Stack: custom CLI framework (`src/framework/`), Node 22+, ESM, ky for HTTP, Vitest, and Vite+ formatting and linting. +This package is the Node 22+, ESM TypeScript implementation of `difyctl`. Development also requires the Bun version pinned in `.bun-version`; command-tree generation and the `dev`, `test`, and `build` pre-scripts invoke it. Read [`ARD.md`] before adding a command or changing shared CLI infrastructure. Read `src/commands/AGENTS.md` for command-folder and registry rules. -> Architecture patterns, scaffolding recipe, printer chain, strategy pattern, testing conventions, anti-patterns: see **[`ARD.md`]**. +## Architecture Boundaries -## Code rules +- Every leaf command extends `DifyCommand`; command classes own framework parsing and delegate behavior to domain modules. +- Each command folder keeps its framework shell in `index.ts`. Extract behavior into sibling modules such as `run.ts` and `handlers.ts` when it needs an independently testable owner; those modules receive typed dependencies and do not import `src/framework/`. +- `src/http/` owns ky middleware and client construction; `src/api/` owns resource clients; `src/sys/io/` owns process streams and progress UI; `src/types/` remains a pure data and schema leaf. +- Preserve flags, output, and exit codes during refactors. Do not add dependencies or compatibility shims unless the task explicitly requires them. +- `ARD.md` owns CLI code structure. Keep wire behavior aligned with typed API clients and the real mock-server behavior tests. -- **Spaces, not tabs.** -- **Minimum comments.** Code speak for self. Comment only non-obvious WHY — hidden constraints, subtle invariants, bug-workaround notes. Never restate code. Never reference tasks, PRs, current callers. -- **No magic strings or numbers.** Enums or named constants for bounded value sets. -- **No long positional arg lists.** Use options objects. -- **No long if/switch ladders on discriminator.** Polymorphism, dispatch tables, or strategy pattern. Name concept, let implementations plug in. -- **No `any`. No `unknown` outside genuine wire boundaries** (HTTP body parse, env vars). Narrow types everywhere else. -- **Avoid `!` non-null assertions.** Narrow instead. -- **`readonly` on inputs not mutated.** -- **Discriminated unions** for variant data (SSE events, run outputs, error shapes), not optional-field bags. -- **No backwards-compat shims.** No re-exports of old names, no `// removed:` markers, no deprecation notes. Delete, update callers. -- **No new dependencies without explicit approval.** -- **No CLI behavior changes in refactor commit.** Same flags, same output, same exit codes. -- **Every leaf command extends `DifyCommand`.** Add `static agentGuide` string when command benefits from agent workflow docs — see `src/commands/AGENTS.md`. +## Commands -## Layering +Run package scripts from `cli/`: -| Layer | Path | Role | -| --------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| commands | `src/commands/` | Command class shells (extend `DifyCommand`). Only place framework imports run. | -| domain | `src/run/`, `src/get/`, etc. | Plain TS modules. Take typed deps via options. Testable without the framework. | -| api | `src/api/` | One typed client per resource. Each takes `KyInstance`. | -| http | `src/http/` | `createClient` + middleware (auth, retry, logging, error mapping). Only place ky runs. | -| io | `src/io/` | Streams + spinner. Fence between data-out and progress UI. | -| printers | `src/printers/` | `CompositePrintFlags` + `-o {json,yaml,name,wide,text}` matrix. | -| errors | `src/errors/` | `BaseError`, `ErrorCode` enum, `ExitCode` enum, dispatch table, `formatErrorForCli`. | -| guide | `src/commands/**//guide.ts` | Per-command agent guide string. Export `agentGuide`, assign `static agentGuide = agentGuide` in command class. Surfaced via `--help`. | -| cache | `src/cache/` | On-disk caches (app-info, etc.). | -| auth | `src/auth/` | Hosts file, token store, login flow. | -| config | `src/config/` | XDG dir resolution, config.yml load/save. | -| workspace | `src/workspace/` | Resolver: flag → env → bundle. | -| types | `src/types/` | Pure data + zod schemas for server contracts. No runtime imports outward. | +- Source CLI: `pnpm dev [args...]` +- Tests: `pnpm test` +- Build: `pnpm build` +- Regenerate and verify the registry: `pnpm tree:gen` and `pnpm tree:check` -## Command Structure +Run the scoped static check from the repository root with `vp check cli`. -Scaffold recipe + checklist: see `ARD.md §New command scaffold`. Full folder convention (subcommands, guide.ts): see `src/commands/AGENTS.md`. - -Layer rules: - -- Commands thin shells. Use `this.authedCtx(opts)` for bearer context; delegate to domain function. -- Domain receives deps via options; never imports `src/framework/`. -- Only `src/http/client.ts` and `src/api/*` import ky at runtime; elsewhere use `import type { KyInstance }`. -- `process.*` lives in `src/io/`, `src/store/dir.ts`, `src/util/browser.ts`. Nowhere else. -- No circular imports. `types/` pure leaf. - -## Dev commands - -```sh -pnpm install # one-time -pnpm dev [args...] # run CLI from source (no -- separator) -pnpm test # vitest -pnpm test:coverage # with coverage -pnpm -w check # repository-wide static check -pnpm -w check:fix # repository-wide static fixes -pnpm build # production bundle (vp pack) -pnpm tree:gen # regenerate src/commands/tree.ts (registry) -pnpm tree:check # verify tree.ts is up-to-date with the fs -``` - -Release binaries (5 platform targets, Bun-compiled) are produced by `pnpm build:bin` (called from `.github/workflows/cli-release.yml`). - -## Tests - -- Behavior tests run against real Hono mock at `test/fixtures/dify-mock/`. No `nock`, `msw`, or `fetchMock` — every test exercises real HTTP. -- Test files co-located: `foo.test.ts` next to `foo.ts`. -- The repository-wide static check and full test suite must be green before any commit. - -## Spec docs (`docs/specs/`) - -Behavior contracts. Living tree — amended in place, no version subfolders. - -**Keep:** HTTP wire shape (req/resp JSON, headers, status codes), SQL DDL, Redis keys + TTL, state transitions, audit event names + payload, error/exit codes, rate-limit values, JWS/cookie envelope claims. - -**Cut:** language type decls, internal helper sigs, decorator snippets, file-path tables, pseudocode mirroring code, "Open items"/"Handler walk"/"CI guard"/"Migration" sections, rationale (`Rejected:`/`Why X not Y`/`Historical note:`/product comparisons), release-pipeline lines, version-pinning (`in v1.0`, `post-v1.0`, milestone codes), frontmatter `date`/`status`/`author`. - -**Test:** "rewrite in Rust tomorrow, does spec hold?" HTTP/SQL/Redis stays; type defs go. - -**Rules:** behavior, not rationale. One topic per file; cross-refs = `auth.md §Storage`. Tables beat prose. Code wins on drift — update spec. - -## Out of scope for unrelated work - -Do not modify in passing: - -- `test/fixtures/dify-mock/` public surface (endpoints, JSON shapes, status codes, scenario names) — that's the dify-api contract. -- `bin/`, `scripts/`, `Makefile`, `lint.config.ts`, `tsconfig*.json`, `package.json` (unless the change is required by the task). - -## Commits - -- One concern per commit. Style: `(): ` lowercase. Body explains why if non-obvious. -- Never push, amend, force-push, or skip hooks (`--no-verify`) without explicit user approval. +Behavior tests use the real Hono server under `test/fixtures/dify-mock/`; do not replace it with `nock`, `msw`, or `fetchMock`. Keep tests colocated with their source files. [`ARD.md`]: ARD.md diff --git a/cli/ARD.md b/cli/ARD.md index a87b01dd85f..e485e74be60 100644 --- a/cli/ARD.md +++ b/cli/ARD.md @@ -2,8 +2,6 @@ Onboarding ref for `dify/cli/` contributors. Cover canonical patterns, layer contracts, scaffolding recipe, dev workflow, anti-patterns. Read before adding command or touching shared infra. -Spec authority: [`docs/specs/`]. Specs own HTTP wire shape + server behavior; this file owns CLI code structure. - --- ## Project layout @@ -17,7 +15,7 @@ src/ config/ config.yml read/write errors/ BaseError, ErrorCode, exit codes http/ ky client factory + middleware - io/ IOStreams, spinner, printer chain + sys/io/ IOStreams, prompts, spinner, output rendering limit/ --limit flag parsing types/ shared TypeScript types util/ small pure helpers @@ -38,17 +36,17 @@ src/commands/// Examples: `get/app/`, `auth/devices/revoke/`, `describe/app/`. -**2. Mandatory files** +**2. Mandatory file** -| File | Responsibility | -| ---------- | --------------------------------------------------------------------------------------- | -| `index.ts` | `DifyCommand` subclass. Flag/arg declaration + `run()` wiring only. No business logic. | -| `run.ts` | Pure async function. Typed options + deps. Returns string. No `src/framework/` imports. | +| File | Responsibility | +| ---------- | ------------------------------------------------------------------------------------ | +| `index.ts` | `DifyCommand` subclass. Owns flag/arg parsing, framework output, and command wiring. | **3. Optional files — add as needed** | File | Purpose | | ------------------ | ------------------------------------------------------------------ | +| `run.ts` | Typed behavior owner when logic merits independent tests or reuse | | `handlers.ts` | Output types implementing `FormattedPrintable` or `TablePrintable` | | `payload-shape.ts` | Response type narrowing/transformation | | `run.test.ts` | Behavior tests against `run.ts` | @@ -58,12 +56,12 @@ Examples: `get/app/`, `auth/devices/revoke/`, `describe/app/`. - [ ] `index.ts` extends `DifyCommand` - [ ] Authed command calls `this.authedCtx()`; non-authed skips -- [ ] No try/catch in `run()` — `DifyCommand.catch()` handles `BaseError` -- [ ] `run.ts` returns string; no direct stdout write -- [ ] `run.ts` no `src/framework/` imports +- [ ] Let the command boundary handle `BaseError`; catch only when the command owns recovery +- [ ] Keep framework parsing and output construction in `index.ts` +- [ ] When present, `run.ts` returns typed behavior data or owns explicit streaming/interactive I/O and does not import `src/framework/` - [ ] HTTP client via factory dep, not direct -- [ ] `run.test.ts` written before impl (test-first) -- [ ] `pnpm tree:gen` run after adding command (updates `src/commands/tree.ts`) +- [ ] Add focused behavior tests when the command changes an observable contract +- [ ] `pnpm tree:gen` run after adding command (updates `src/commands/tree.generated.ts`) - [ ] README command table updated by hand --- @@ -74,27 +72,22 @@ All commands extend `DifyCommand`, not `Command`. ```typescript export default class MyCommand extends DifyCommand { - async run(): Promise { + async run(argv: string[]) { const { args, flags } = this.parse(MyCommand, argv) - // Authed: authedCtx() sets outputFormat + builds context - const ctx = await this.authedCtx({ format: flags.output }) - - process.stdout.write( - await runMyThing( - { - // args - }, - { bundle: ctx.bundle, http: ctx.http, io: ctx.io }, - ), + const ctx = await this.authedCtx({ retryFlag: undefined, format: flags.output }) + const result = await runMyThing( + { id: args.id }, + { active: ctx.active, http: ctx.http, io: ctx.io }, ) + return formatted({ format: flags.output, data: result.data }) } } ``` -**`authedCtx(opts)`** — wraps `buildAuthedContext`. Sets `this.outputFormat` as side effect. Required for any command needing bearer token. +**`authedCtx(opts)`** — wraps `buildAuthedContext` and returns the authenticated registry, account, HTTP, I/O, and optional cache dependencies. Pass the selected output format so authentication failures use the same serialization contract. Required for commands that need a bearer token. -**`catch(err)` override** — auto-handles `BaseError` with format-aware serialization. Never wrap `run()` in try/catch. Throw `BaseError`; base class catches. +The framework runner in `src/framework/run.ts` catches command errors, normalizes unknown failures, and serializes `BaseError` according to the selected output format. Catch inside a command only when that command owns a real recovery path. --- @@ -103,8 +96,8 @@ export default class MyCommand extends DifyCommand { Throw `BaseError`. Never throw raw `Error` for domain failures. ```typescript -import { BaseError } from '../../errors/base.js' -import { ErrorCode } from '../../errors/codes.js' +import { BaseError } from '@/errors/base' +import { ErrorCode } from '@/errors/codes' throw new BaseError({ code: ErrorCode.UsageMissingArg, @@ -113,7 +106,7 @@ throw new BaseError({ }) ``` -`ErrorCode` exhaustive const object — never use raw strings. `exitFor(code)` maps to exit codes auto. `DifyCommand.catch()` calls `formatErrorForCli` with `outputFormat` so JSON/YAML consumers get machine-readable error output. +`ErrorCode` is the exhaustive error-code object; do not scatter raw code strings. `exitFor(code)` maps it to a process exit code, and the framework runner calls `formatErrorForCli` so JSON/YAML consumers receive machine-readable errors. | Exit | Meaning | | ---- | ----------------------------------------- | @@ -122,6 +115,7 @@ throw new BaseError({ | 2 | Usage error (bad flag, missing arg) | | 4 | Auth error (not logged in, token expired) | | 6 | Version/compat error | +| 7 | Rate limited | New error code: add to `ErrorCode` + map to `ExitCode` in `codes.ts`. Never scatter exit codes inline. @@ -172,7 +166,7 @@ Output rendering separated from data fetching via protocol objects. - Data classes implement `TablePrintable` or `FormattedPrintable` from `src/framework/output`. - Streaming commands implement `StreamPrinter` from `src/framework/stream`. -- `index.ts` wraps the result with `table({format, data})` or `formatted({format, data})` and returns it; the base class calls `stringifyOutput()`. +- `index.ts` wraps the result with `table({format, data})` or `formatted({format, data})` and returns it; `src/framework/run.ts` calls `stringifyOutput()`. - Commands that write incrementally (streaming) write directly from the strategy via `deps.io.out.write(stringifyOutput(...))`. ```typescript @@ -220,51 +214,15 @@ New mode = new class + one line in picker. Singletons avoid per-call allocation. ## HTTP clients -One file per resource under `src/api/`. Each exports class wrapping `KyInstance`. +Keep resource clients under `src/api/`. They receive the shared `HttpClient` and call generated oRPC operations through `createOpenApiClient(...)` when the OpenAPI contract covers the endpoint. Reuse generated request and response types instead of duplicating wire shapes. -```typescript -export class AppsClient { - private readonly http: KyInstance - constructor(http: KyInstance) { - this.http = http - } - - async list(params: ListParams): Promise { - /* ... */ throw new Error('elided') - } - async describe(id: string, workspaceId: string, fields: string[]): Promise { - /* ... */ throw new Error('elided') - } -} -``` - -Inject via factory dep in `run.ts` for testability: - -```typescript -type GetAppDeps = { - appsFactory?: (http: KyInstance) => AppsClient -} -// default: (h) => new AppsClient(h) -``` - -Never instantiate clients in `index.ts`. +Pass `HttpClient` into behavior owners. Add a client or factory dependency only when it owns a real substitution or lifecycle boundary; behavior tests normally exercise the real client stack against `test/fixtures/dify-mock/`. Keep client construction out of `index.ts` so the command remains a framework and output boundary. --- ## Testing -**Test-first.** Write failing test, run to confirm fail, then implement. - -Tests live in `run.test.ts` alongside command. Test `run.ts` direct — never the `DifyCommand` class. - -```typescript -const io = bufferStreams() -const result = await runGetApp( - { format: 'json', appId: 'app-1' }, - { bundle, http: mockHttp, io, appsFactory: () => fakeClient }, -) -expect(JSON.parse(result).data).toHaveLength(1) -``` +Keep tests beside the owner as `*.test.ts`. When a command has a behavior module, test that public function directly for domain and protocol behavior. Test the command class or framework boundary when argument parsing, flags, help, output construction, or command wiring is the observable contract. Establish a failing case first when practical for behavior changes and bug fixes. ### dify-mock fixture server @@ -306,14 +264,14 @@ expect(JSON.parse(out).workspaces).toHaveLength(2) | `pnpm dev [args]` | Run CLI from source during dev | | `pnpm test` | Full vitest suite — run before every commit | | `pnpm test:coverage` | Coverage report | -| `pnpm -w check` | Repository-wide static check | -| `pnpm -w check:fix` | Repository-wide static fixes | +| `vp check cli` | Scoped static check from the repository root | +| `vp check --fix cli` | Scoped static fixes from the repository root | | `pnpm build` | Production bundle (`vp pack`) | -| `pnpm tree:gen` | Regenerate `src/commands/tree.ts` (registry) | -| `pnpm tree:check` | Verify `tree.ts` matches the filesystem | +| `pnpm tree:gen` | Regenerate `src/commands/tree.generated.ts` | +| `pnpm tree:check` | Verify the generated tree matches the commands | | `pnpm build:bin` | Cross-compile standalone binaries via Bun (CI) | -**`pnpm tree:gen` rule:** run after adding, removing, renaming any command. The generated `tree.ts` is the runtime command registry — stale tree causes commands to be invisible at runtime. (Runs implicitly via `prebuild`/`predev`/`pretest`.) +**`pnpm tree:gen` rule:** run after adding, removing, or renaming any command. The generated `tree.generated.ts` is the runtime command registry; a stale tree makes commands invisible at runtime. It also runs through `prebuild`, `predev`, and `pretest`. **README hand-maintained.** When adding a command, update the command table in `README.md` manually. @@ -331,7 +289,7 @@ The repository runs Vite+ Oxlint as the primary code-quality linter, an explicit | `unicorn/no-new-array` | Use `Array.from({ length: n })` not `new Array(n)` | | `noUncheckedIndexedAccess` (tsc) | `arr[i]` is `T \| undefined`; guard before use | -Run `pnpm -w check:fix` for Oxlint, ESLint, TypeScript, and Oxfmt fixes and diagnostics. +Run `vp check --fix cli` from the repository root for scoped formatting, lint, and TypeScript fixes and diagnostics. --- @@ -350,14 +308,12 @@ Run `pnpm -w check:fix` for Oxlint, ESLint, TypeScript, and Oxfmt fixes and diag | Pattern | Do instead | | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `if (format === 'json') { ... }` in `run.ts` | Printer handler per format | -| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `DifyCommand.catch()` handles | +| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `src/framework/run.ts` normalizes and formats it | | Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` | | `enabled: !isHuman` in `runWithSpinner` | Set `outputFormat` on `IOStreams`; spinner auto-detects | | Long positional arg lists | Options struct | | `Record` dispatch map | Named singletons + picker function | | `src/framework/` import in `run.ts`, `api/`, or `auth/` | Framework imports belong in `index.ts`, `handlers.ts`, and strategies only | | `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` | -| `console.log` in `src/` | Return string from `run.ts`; write in `index.ts` | +| `console.log` in `src/` | Return `CommandOutput` from the command or use owned I/O for streaming | | New dependency without approval | Check first | - -[`docs/specs/`]: docs/specs/ diff --git a/cli/src/commands/AGENTS.md b/cli/src/commands/AGENTS.md index 5df83849779..6d675ef70a9 100644 --- a/cli/src/commands/AGENTS.md +++ b/cli/src/commands/AGENTS.md @@ -13,7 +13,7 @@ src/commands/ / / index.ts ← command class (extends DifyCommand; the ONLY file the registry discovers) - run.ts ← business logic (not a command, invisible to the registry) + run.ts ← optional behavior owner (not a command, invisible to the registry) handlers.ts ← helpers guide.ts ← agent guide string (optional) *.test.ts ← tests @@ -23,7 +23,7 @@ src/commands/ .ts ``` -The registry generator (`pnpm tree:gen` → `src/commands/tree.ts`) discovers +The registry generator (`pnpm tree:gen` → `src/commands/tree.generated.ts`) discovers commands only via `**/index.+(js|cjs|mjs|ts)`. All other files in command folders are invisible to the registry — add freely without glob exclusions. Folders prefixed with `_` (e.g. `_shared/`, `_strategies/`) are excluded from @@ -32,7 +32,7 @@ registry discovery and from coverage checks. ## Adding a new command 1. Create `src/commands///index.ts` extending `DifyCommand`. -1. Add business logic in sibling files (e.g. `run.ts`, `handlers.ts`). +1. Keep small owner-local behavior in `index.ts`; extract sibling modules such as `run.ts` or `handlers.ts` when logic needs independent tests, reuse, or a clearer owner. 1. Run `pnpm tree:gen` to regenerate the command tree (also runs implicitly via `prebuild`/`predev`/`pretest`). 1. Run `pnpm test` to verify coverage. @@ -54,7 +54,9 @@ registry discovery and from coverage checks. import { agentGuide } from './guide.js' export default class MyCmd extends DifyCommand { - static agentGuide = agentGuide + override agentGuide(): string { + return agentGuide + } } ``` 1. The guide appears at the bottom of `difyctl --help` automatically. diff --git a/dify-agent/AGENTS.md b/dify-agent/AGENTS.md index 43c68448f20..ee33943a3aa 100644 --- a/dify-agent/AGENTS.md +++ b/dify-agent/AGENTS.md @@ -1,184 +1,21 @@ # Agent Guide -## Notes for Agent (must-check) +Read surrounding docstrings and non-obvious comments before changing behavior. They are local contracts; update them only when their owned behavior changes, and keep them aligned with the current code. Read `docs/dify-agent/index.md` when changing the public runtime contract. -Before changing any source code under this folder, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec. +## Commands -Look for: +Run package commands from `dify-agent/`: -- The module (file) docstring at the top of a source code file -- Docstrings on classes and functions/methods -- Paragraph/block comments for non-obvious logic +- Lint: `make check` +- Format and fix lint: `make fix` +- Type check: `make typecheck` +- Tests: `make test` -### What to write where +Use the package's `uv` environment and Pydantic v2 APIs. Inspect current dependency source or official documentation before integrating, implementing, or mocking an API whose runtime contract is not established locally. -- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse. -- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing. - - Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard. - - Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes. -- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used). - - If the class is intentionally stateful, note what state exists and what methods mutate it. - - If concurrency/async assumptions matter, state them explicitly. -- **Function/method docstring**: behavioural contract. - - Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions. - - Add examples only when they prevent misuse. -- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states. - - Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality. +## Tests And Boundaries -### Rules (must follow) - -In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments. - -- **Before working** - - Read the notes in the area you’ll touch; treat them as part of the spec. - - If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality. - - If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour). -- **During working** - - Keep the notes in sync as you discover constraints, make decisions, or change approach. - - If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants. - - Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct. - - Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions. -- **When finishing** - - Update the notes to reflect what changed, why, and any new edge cases/tests. - - Remove or rewrite any comments that could be mistaken as current guidance but no longer apply. - - Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery. - -## Coding Style - -This is the default standard for backend code in this repo. Follow it for new code and use it as the checklist when reviewing changes. - -### Linting & Formatting - -- Use Ruff for formatting and linting (follow `.ruff.toml`). -- Keep each line under 120 characters (including spaces). - -### Naming Conventions - -- Use `snake_case` for variables and functions. -- Use `PascalCase` for classes. -- Use `UPPER_CASE` for constants. - -### Typing & Class Layout - -- Code should usually include type annotations that match the repo’s current Python version (avoid untyped public APIs and “mystery” values). -- Prefer modern typing forms (e.g. `list[str]`, `dict[str, int]`) and avoid `Any` unless there’s a strong reason. -- For dictionary-like data with known keys and value types, prefer `TypedDict` over `dict[...]` or `Mapping[...]`. -- For optional keys in typed payloads, use `NotRequired[...]` (or `total=False` when most fields are optional). -- Keep `dict[...]` / `Mapping[...]` for truly dynamic key spaces where the key set is unknown. - -```python -from datetime import datetime -from typing import NotRequired, TypedDict - - -class UserProfile(TypedDict): - user_id: str - email: str - created_at: datetime - nickname: NotRequired[str] -``` - -- For classes, declare all member variables explicitly with types at the top of the class body (before `__init__`), even when the class is not a dataclass or Pydantic model, so the class shape is obvious at a glance: - -```python -from datetime import datetime - - -class Example: - user_id: str - created_at: datetime - - def __init__(self, user_id: str, created_at: datetime) -> None: - self.user_id = user_id - self.created_at = created_at -``` - -- For dataclasses, prefer `field(default_factory=...)` over `field(init=False)` when a default can be provided declaratively. -- Prefer dataclasses with `slots=True` when defining lightweight data containers: - -```python -from dataclasses import dataclass -from datetime import datetime - - -@dataclass(slots=True) -class Example: - user_id: str - created_at: datetime -``` - -### General Rules - -- Use Pydantic v2 conventions. -- Use `uv` for Python package management in this repo (usually with `--project dify-agent`). -- Use `make typecheck` to run `basedpyright` against `dify-agent/src` and `dify-agent/tests`. -- Keep type checking passing after every edit you make. -- Use `pytest` for all tests in this package. -- When integrating with, implementing, or mocking a dependency, inspect the dependency's source code to confirm its API shape and runtime behavior instead of guessing from names alone. -- Prefer simple functions over small “utility classes” for lightweight helpers. -- Avoid implementing dunder methods unless it’s clearly needed and matches existing patterns. -- Keep code readable and explicit—avoid clever hacks. - -### Testing - -- Work in TDD style: write or update a failing test first when changing behavior, then make the implementation pass, then refactor while keeping tests and typecheck green. -- Use `make test` to run the agent pytest suite. -- Keep local tests under `dify-agent/tests/local/`. -- Mirror the `dify-agent/src/` package structure inside `dify-agent/tests/local/` so test locations stay predictable. - -#### Local Tests - -- Write local tests for stable, externally observable behavior that can run quickly without real external services. -- In this repo, code, comments, docs, and tests are expected to change together. Because of that, a local test is only useful if it would still be correct after an internal refactor that does not change the intended contract. -- Local tests should verify: - - what callers and downstream code can observe and rely on - - how the unit is expected to use its dependencies at the boundary - - how the unit handles dependency success, failure, empty responses, malformed responses, and documented error cases - - documented invariants, error mapping, and output/input shape guarantees -- When asserting dependency interactions, assert only the parts of the request or response that are part of the real boundary contract. Do not over-specify incidental details that callers or dependencies do not rely on. -- It is acceptable to mock dependencies in local tests, but only when the mock represents a real contract, schema, documented behavior, or known regression. -- Tests may use line-scoped type-ignore comments when intentionally exercising runtime validation paths that static typing would normally reject. Keep the ignore on the exact invalid call. -- Do not use local tests to prove real integration, network wiring, serialization, framework configuration, or third-party runtime behavior; cover those in higher-level tests. -- Meaningless local tests include: - - tests that only mirror the current implementation or must be updated whenever internal code changes even though the contract did not change - - tests of private helpers, local variables, temporary state, internal branching, or exact internal call order unless those details are part of the published contract - - tests with mocked dependency behavior that is invented only to make the current implementation pass - - tests that add no value beyond static type checking or linting - -### Logging & Errors - -- Never use `print`; use a module-level logger: - - `logger = logging.getLogger(__name__)` -- Include tenant/app/workflow identifiers in log context when relevant. -- Raise domain-specific exceptions and translate them into HTTP responses in controllers. -- Log retryable events at `warning`, terminal failures at `error`. - -### Pydantic Usage - -- Define DTOs with Pydantic v2 models and forbid extras by default. -- Use `@field_validator` / `@model_validator` for domain rules. - -Example: - -```python -from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator - - -class TriggerConfig(BaseModel): - endpoint: HttpUrl - secret: str - - model_config = ConfigDict(extra="forbid") - - @field_validator("secret") - def ensure_secret_prefix(cls, value: str) -> str: - if not value.startswith("dify_"): - raise ValueError("secret must start with dify_") - return value -``` - -### Generics & Protocols - -- Use `typing.Protocol` to define behavioural contracts (e.g., cache interfaces). -- Apply generics (`TypeVar`, `Generic`) for reusable utilities like caches or providers. -- Validate dynamic inputs at runtime when generics cannot enforce safety alone. +- Keep local tests under `tests/local/` and mirror the `src/` package structure. +- Test stable behavior and real dependency boundaries. Do not use local mocks to claim real network, framework wiring, serialization, or third-party runtime coverage. +- Keep tests, public docs, and local contracts aligned with behavior changes. +- Preserve the existing runtime and layer owners; do not add generic utilities or compatibility boundaries to bypass them. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index abbf2a27b4b..a1cb741fd26 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -1,381 +1,62 @@ # E2E -This package contains the repository-level end-to-end tests for Dify. +This package contains Dify's repository-level Cucumber scenarios with Playwright as the browser layer. This file owns current package architecture, runtime, session and tag semantics, seed, protocol, and cleanup contracts. The repo-local `e2e-cucumber-playwright` skill owns authoring and review methodology; feature-specific facts belong in the nearest feature `AGENTS.md`. -This file is the canonical package guide for `e2e/`. Keep detailed workflow, architecture, debugging, and reporting documentation here. Keep `README.md` as a minimal pointer to this file so the two documents do not drift. +## Commands -The suite uses Cucumber for scenario definitions and Playwright as the browser execution layer. +Run commands from the repository root. Install dependencies and browsers once with `pnpm install` and `pnpm -C e2e e2e:install`. Run only one local `pnpm -C e2e e2e*` process at a time because runners share ports, auth state, and log paths. -It tests: +- Existing initialized instance: `pnpm -C e2e e2e` +- Reset, initialize, and run deterministic scenarios: `pnpm -C e2e e2e:full` +- Tagged subset: `pnpm -C e2e e2e -- --tags @smoke` +- Headed debugging: `pnpm -C e2e e2e:headed -- --tags @smoke` +- External runtime preparation and run: `pnpm -C e2e e2e:external:prepare`, then `pnpm -C e2e e2e:external` +- Reset persisted E2E state: `pnpm -C e2e e2e:reset` +- Middleware lifecycle: `pnpm -C e2e e2e:middleware:up` and `pnpm -C e2e e2e:middleware:down` +- Scoped static checks: `vp check e2e` -- backend API started from source -- frontend served from the production artifact -- middleware services started from Docker +The runner reuses `web/.next/BUILD_ID` when present. Set `E2E_FORCE_WEB_BUILD=1` to force a frontend rebuild. Use `E2E_BROWSER=webkit` for focused cross-browser runs and `E2E_SLOW_MO=500` with a headed command for local action debugging. -## Prerequisites +## Runtime Ownership -- Node.js `^22.22.1` -- `pnpm` -- `uv` -- Docker +- `scripts/setup.ts` owns reset, middleware, backend, and frontend startup. +- `scripts/run-cucumber.ts` owns E2E orchestration and Cucumber invocation. +- `support/web-server.ts` owns frontend reuse, readiness, and shutdown. +- `features/support/hooks.ts` owns shared auth bootstrap, scenario lifecycle, and diagnostics. +- `features/support/world.ts` owns `DifyWorld`, the per-scenario behavior `BrowserContext`, and its authenticated setup and cleanup client. Browser and API identities remain separate so unauthenticated and logout journeys cannot invalidate fixture ownership. +- Cross-actor scenarios keep each actor in a separate `BrowserContext` and typed `DifyWorld` state so diagnostics and cleanup cover every actor. +- `features/step-definitions/` contains capability-oriented glue; `common/` is reserved for genuinely cross-capability steps. +- Step definitions that access World state use `async function (this: DifyWorld, ...)`; arrow functions cannot receive Cucumber's bound World instance. -Run the following commands from the repository root. +An uninitialized instance is installed and authenticated lazily; an initialized instance signs in and reuses authenticated state. Full runs prove reset and bootstrap during setup rather than through a Gherkin scenario. Cucumber's exit status is the behavior gate, and the runner also requires at least one `testCaseStarted` message so an empty tag selection cannot pass. Do not replace this gate with scenario-count baselines or skipped-scenario allowlists. -Install Playwright browsers once: +## Tags And External Runtime -```bash -pnpm install -pnpm -C e2e e2e:install -``` +- Default scenarios use shared authenticated storage state. `@unauthenticated` creates a clean context; `@authenticated` is an intent and selection tag only. +- `@prepared` requires the strict post-merge seed profile. +- `@external-model` and `@external-tool` identify scenarios that call real external runtimes. Deterministic commands exclude these tags; external commands are opt-in. +- `@microphone` uses the checked-in fake audio fixture and an isolated Chromium context. +- `@browser-smoke` runs focused keyboard and navigation coverage in Chromium and WebKit CI lanes. +- Feature-owned services use their own tags. Agent v2 runtime scenarios use `@agent-backend-runtime` and require the explicit runtime-availability step. Set `E2E_START_AGENT_BACKEND=1` to start it locally, or provide `E2E_AGENT_BACKEND_URL` / `AGENT_BACKEND_BASE_URL`. -`pnpm install` is resolved through the repository workspace and uses the shared root lockfile plus `pnpm-workspace.yaml`. +Do not overload runtime tags to imply unrelated services or silently skip behavior when a required fixture is missing. -Run only one `pnpm -C e2e e2e*` process against a local workspace at a time. Separate runner processes share the frontend port, backend port, auth bootstrap state, and log paths; running them in parallel can create startup or authorization failures that are not scenario failures. +## Browser, API, And Contract Boundaries -Use root lint plus the package type check as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue: +The action under test belongs to the browser. APIs may prepare fixtures, poll persistence, and clean up; they do not replace the user's `When` action. Prefer a user-observable browser result unless persisted backend state is the contract under test. -```bash -vp lint --fix --quiet -pnpm -C e2e type-check -``` +For ordinary Console JSON and representable multipart operations, use the scenario- or process-owned generated oRPC client with request and response validation enabled. Call generated operations directly. Do not add handwritten endpoint URLs, duplicate DTOs or schemas, response casts, one-to-one forwarding wrappers, mutable cross-scenario clients, or TanStack Query caching. -Common commands: +Keep helpers only when they own fixture construction, multi-operation orchestration, cleanup registries, invariants, eventual-consistency polling, narrowed test views, or a protocol adapter. SSE, binary downloads, redirect-only flows, external services, and infrastructure readiness may use centralized adapters under their real owner. -```bash -# deterministic regression against an initialized instance -# expects backend API, frontend artifact, and middleware stack to already be running -pnpm -C e2e e2e +Validation failures are contract failures. Trace them to the backend schema owner, update `api/controllers/API_SCHEMA_GUIDE.md` contracts when required, regenerate `@dify/contracts`, and keep the scenario aligned with the product's real state owner. Do not disable validation or add fallback schemas to make E2E pass. -# reset, initialize, and run deterministic scenarios -# starts required middleware/dependencies for you -pnpm -C e2e e2e:full +## Seeds, Cleanup, And Diagnostics -# run a tagged subset -pnpm -C e2e e2e -- --tags @smoke +- Generate disposable resource names through `support/naming.ts` with an `E2E` prefix. +- Keep deterministic upload material in `fixtures/test-materials/` and resolve it through `support/test-materials.ts`. +- Seed scripts own shared long-lived fixtures; scenarios own disposable resources they create and must register cleanup. +- Use typed `DifyWorld` cleanup fields for known resource types and `registerCleanup(...)` for additional lifecycle owners. Registered callbacks run LIFO after typed cleanup queues. +- Remove child and referencing resources before owners. Attach cleanup failures to the report instead of swallowing them. -# prepare external runtime seed resources for opt-in external suites -pnpm -C e2e e2e:external:prepare - -# run scenarios that call real external providers -pnpm -C e2e e2e:external - -# headed browser -pnpm -C e2e e2e:headed -- --tags @smoke - -# slow down browser actions for local debugging -E2E_SLOW_MO=500 pnpm -C e2e e2e:headed -- --tags @smoke - -# focused keyboard and cross-browser smoke coverage -E2E_BROWSER=webkit pnpm -C e2e e2e -- --tags @browser-smoke -``` - -Frontend artifact behavior: - -- if `web/.next/BUILD_ID` exists, E2E reuses the existing build by default -- if you set `E2E_FORCE_WEB_BUILD=1`, E2E rebuilds the frontend before starting it - -## Lifecycle - -```mermaid -flowchart TD - A["Start E2E run"] --> B["run-cucumber.ts orchestrates setup/API/frontend"] - B --> C["support/web-server.ts starts or reuses frontend directly"] - C --> D["Cucumber loads config, steps, and support modules"] - D --> E["The first Before hook lazily bootstraps shared auth state"] - E --> F{"Which command is running?"} - F -->|`pnpm -C e2e e2e`| G["Run deterministic scenarios; exclude @prepared and external runtime"] - F -->|`pnpm -C e2e e2e:full*`| H["Reset and run deterministic scenarios; exclude @prepared and external runtime"] - G --> I["Per-scenario BrowserContext from shared browser"] - H --> I - I --> J["Failure artifacts written to cucumber-report/artifacts"] -``` - -Ownership is split like this: - -- `scripts/setup.ts` is the single environment entrypoint for reset, middleware, backend, and frontend startup -- `run-cucumber.ts` orchestrates the E2E run and Cucumber invocation -- `support/web-server.ts` manages frontend reuse, startup, readiness, and shutdown -- `features/support/hooks.ts` manages auth bootstrap, scenario lifecycle, and diagnostics -- `features/support/world.ts` owns the per-scenario behavior BrowserContext and authenticated setup/cleanup client; their identities remain separate so unauthenticated and logout journeys cannot invalidate fixture ownership -- `features/step-definitions/` holds domain-oriented glue so the official VS Code Cucumber plugin works with default conventions when `e2e/` is opened as the workspace root - -Package layout: - -- `features/`: Gherkin scenarios grouped by capability -- `features/step-definitions/`: domain-oriented step definitions -- `features/support/hooks.ts`: suite lifecycle, auth-state bootstrap, diagnostics -- `features/support/world.ts`: shared scenario context -- `support/web-server.ts`: typed frontend startup/reuse logic -- `scripts/setup.ts`: reset and service lifecycle commands -- `scripts/run-cucumber.ts`: Cucumber orchestration entrypoint - -Behavior depends on instance state: - -- uninitialized instance: completes install and stores authenticated state -- initialized instance: signs in and reuses authenticated state - -The `pnpm -C e2e e2e:full*` flows prove reset and authentication bootstrap by failing setup when initialization cannot complete; they do not model bootstrap state as a Gherkin scenario. Deterministic runs exclude `@prepared`, `@external-model`, and `@external-tool`. Post-merge first seeds required fixtures, then runs prepared and external scenarios. - -Reset all persisted E2E state: - -```bash -pnpm -C e2e e2e:reset -``` - -This removes: - -- `docker/volumes/db/data` -- `docker/volumes/redis/data` -- `docker/volumes/weaviate` -- `docker/volumes/plugin_daemon` -- `e2e/.auth` -- `e2e/.logs` -- `e2e/.logs-non-external` -- `e2e/.logs-webkit` -- `e2e/cucumber-report` -- `e2e/cucumber-report-non-external` -- `e2e/cucumber-report-webkit` -- `e2e/seed-report` - -Start the full middleware stack: - -```bash -pnpm -C e2e e2e:middleware:up -``` - -Stop the full middleware stack: - -```bash -pnpm -C e2e e2e:middleware:down -``` - -The middleware stack includes: - -- PostgreSQL -- Redis -- Weaviate -- Sandbox -- SSRF proxy -- Plugin daemon - -Fresh install verification: - -```bash -pnpm -C e2e e2e:full -``` - -Run the Cucumber suite against an already running middleware stack: - -```bash -pnpm -C e2e e2e:middleware:up -pnpm -C e2e e2e -pnpm -C e2e e2e:middleware:down -``` - -Artifacts and diagnostics: - -- `cucumber-report/report.html`: HTML report -- `cucumber-report/report.ndjson`: Cucumber Messages report -- `cucumber-report/artifacts/`: failure screenshots and HTML captures -- `cucumber-report-non-external/`: Chromium core report preserved before later CI lanes -- `cucumber-report-webkit/`: focused WebKit keyboard/browser smoke report -- `.logs/cucumber-api.log`: backend startup log -- `.logs/cucumber-web.log`: frontend startup log -- `.logs-non-external/`: non-external logs preserved before an external CI run -- `.logs-webkit/`: focused WebKit lane logs -- `seed-report/`: JSON readiness reports emitted by external runtime seed packs - -Cucumber's exit status is the behavior gate. The runner also requires at least one -`testCaseStarted` message so an empty or broken tag selector cannot pass silently. Do not add -scenario-count baselines or skipped-scenario allowlists. - -Open the HTML report locally with: - -```bash -open cucumber-report/report.html -``` - -## Scenario admission and behavior ownership - -Add an E2E scenario only when it protects a critical user journey and a cross-boundary result that -cheaper owner-level tests do not already prove. A control changing its own label is not sufficient -E2E evidence when component or integration tests can own that contract. - -Start from product truth, including real defaults and actor roles. API fixtures may establish -preconditions, but they must not manufacture an opposite state merely to make the intended action -look meaningful. When a product default is part of the journey, make it explicit and observable. - -For cross-actor journeys, isolate each actor's browser state, keep their pages in typed `DifyWorld` -state, and include them in failure diagnostics and cleanup. Assert the downstream user-observable -effect, not only the initiating control's local state. - -When a run exposes behavior that conflicts with the intended product contract, identify the first -layer that misclassifies the business state. Fix that owner or report the mismatch explicitly; do -not make the E2E pass by encoding an accidental redirect, stale label, or misleading error state. - -## Writing new scenarios - -### Workflow - -1. Create a `.feature` file under `features//` -1. Add step definitions under `features/step-definitions//` -1. Reuse existing steps from `common/` and other definition files before writing new ones -1. Run with `pnpm -C e2e e2e -- --tags @your-tag` to verify -1. Run `vp lint --fix --quiet` from the repository root and `pnpm -C e2e type-check` before committing - -### Feature file conventions - -Tag every feature or scenario with a capability tag. Add auth tags only when they clarify intent or change the browser session behavior: - -```gherkin -@datasets @authenticated -Feature: Create dataset - Scenario: Create a new empty dataset - Given I am signed in as the default E2E admin - When I open the datasets page - ... -``` - -- Capability tags (`@apps`, `@auth`, `@datasets`, …) group related scenarios for selective runs -- Auth/session tags: - - default behavior — scenarios run with the shared authenticated storageState unless marked otherwise - - `@unauthenticated` — uses a clean BrowserContext with no cookies or storage - - `@authenticated` — optional intent tag for readability or selective runs; it does not currently change hook behavior on its own -- `@prepared` — deterministic user behavior that requires the strict post-merge seed profile -- `@external-model` — scenario execution can call a real model provider. Use this only for runtime requests, not for scenarios that only require an active model fixture. -- `@external-tool` — scenario execution can call a real third-party tool provider. Use this only for runtime tool execution, not for plugin installation, discovery, or local deterministic tools. -- `@microphone` — runs the scenario in an isolated Chromium instance backed by the checked-in fake audio fixture and grants microphone permission only to that scenario context. -- `@browser-smoke` — focused keyboard and navigation coverage that runs in Chromium with the core suite and again in WebKit on CI. - External runtime commands are opt-in. `pnpm -C e2e e2e:external:prepare` prepares the fixed Agent v2 external-runtime seed and `pnpm -C e2e e2e:external` runs every `@external-model` or `@external-tool` scenario. CI uses `e2e:post-merge:prepare` followed by `e2e:post-merge` to run `@prepared` and external scenarios against one strict seed. - -The Agent v2 external runtime seed also prepares the workspace default Speech-to-Text model. `E2E_SPEECH_TO_TEXT_MODEL_PROVIDER` and `E2E_SPEECH_TO_TEXT_MODEL_NAME` select an existing model or the model configured through `E2E_MODEL_PROVIDER_CREDENTIALS_JSON`; they default to `openai` and `gpt-4o-mini-transcribe`. - -Some external runtime scenarios need feature-owned services in addition to a real model or tool provider. Do not overload `@external-model` or `@external-tool` to mean those services are available. For Agent v2, scenarios that require the standalone `dify-agent` run server use the feature tag `@agent-backend-runtime` plus the explicit step `the Agent v2 runtime backend is available`. Run them with `E2E_START_AGENT_BACKEND=1` to let E2E start `dify-agent` and the shellctl local sandbox required by its `dify.config`/`dify.shell` runtime layers, or set `E2E_AGENT_BACKEND_URL`/`AGENT_BACKEND_BASE_URL` when an existing server should be reused. - -Keep scenarios short and declarative. Each step should describe **what** the user does, not **how** the UI works. - -### Step definition conventions - -```typescript -import type { DifyWorld } from '../../support/world' -import { Then, When } from '@cucumber/cucumber' -import { expect } from '@playwright/test' - -When('I open the datasets page', async function (this: DifyWorld) { - await this.getPage().goto('/datasets') -}) -``` - -Rules: - -- Always type `this` as `DifyWorld` for proper context access -- Use `async function` (not arrow functions — Cucumber binds `this`) -- One step = one user-visible action or one assertion -- Keep steps stateless across scenarios; use `DifyWorld` properties for in-scenario state - -### Locator priority - -Follow the Playwright recommended locator strategy, in order of preference: - -| Priority | Locator | Example | When to use | -| -------- | ------------------ | ----------------------------------------- | ----------------------------------------- | -| 1 | `getByRole` | `getByRole('button', { name: 'Create' })` | Default choice — accessible and resilient | -| 2 | `getByLabel` | `getByLabel('App name')` | Form inputs with visible labels | -| 3 | `getByPlaceholder` | `getByPlaceholder('Enter name')` | Inputs without visible labels | -| 4 | `getByText` | `getByText('Welcome')` | Static text content | -| 5 | `getByTestId` | `getByTestId('workflow-canvas')` | Only when no semantic locator works | - -Avoid raw CSS/XPath selectors. They break when the DOM structure changes. - -### Assertions - -Use `@playwright/test` `expect` — it auto-waits and retries until the condition is met or the timeout expires: - -```typescript -// URL assertion -await expect(page).toHaveURL(/\/datasets\/[a-f0-9-]+\/documents/) - -// Element visibility -await expect(page.getByRole('button', { name: 'Save' })).toBeVisible() - -// Element state -await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled() - -// Negation -await expect(page.getByText('Loading')).not.toBeVisible() -``` - -Do not use manual `waitForTimeout` or polling loops. If you need a longer wait for a specific assertion, pass `{ timeout: 30_000 }` to the assertion. - -### Cucumber expressions - -Use Cucumber expression parameter types to extract values from Gherkin steps: - -| Type | Pattern | Example step | -| ---------- | ------------- | ---------------------------------- | -| `{string}` | Quoted string | `I select the "Workflow" app type` | -| `{int}` | Integer | `I should see {int} items` | -| `{float}` | Decimal | `the progress is {float} percent` | -| `{word}` | Single word | `I click the {word} tab` | - -Prefer `{string}` for UI labels, names, and text content — it maps naturally to Gherkin's quoted values. - -### Scoping locators - -When the page has multiple similar elements, scope locators to a container: - -```typescript -When('I fill in the app name in the dialog', async function (this: DifyWorld) { - const dialog = this.getPage().getByRole('dialog') - await dialog.getByPlaceholder('Give your app a name').fill('My App') -}) -``` - -### Failure diagnostics - -The `After` hook automatically captures diagnostics for failed, ambiguous, pending, undefined, or unknown scenarios: - -- Full-page screenshot (PNG) -- Page HTML dump -- Console errors and page errors - -Artifacts are saved to `cucumber-report/artifacts/` and attached to the HTML report. No extra code needed in step definitions. - -### Seed and fixture contracts - -Use `support/naming.ts` for generated test resource names. New app, Agent, dataset, file, or credential seeds should start with `E2E` so local and shared environments can identify disposable resources. - -Use `fixtures/test-materials/` for checked-in files that scenarios upload, preview, index, or retrieve. Keep these fixtures small and deterministic, and use `support/test-materials.ts` to resolve their absolute paths. - -Seed scripts own long-lived models, plugins, datasets, and fixed apps. Selected scenarios may resolve and verify those fixtures through explicit `Given` steps, but a missing or drifted fixture must fail the scenario. Do not represent environment readiness as Gherkin scenarios and do not conditionally skip behavior. - -Keep package-level support limited to broadly reusable primitives such as API clients, naming, fixture path resolution, and cleanup helpers. Feature-specific seed and fixture contracts belong under the owning feature's support folder. - -Use generated API contracts for Console/Web/Service API request, response, and payload shapes. Import the concrete type directly from `@dify/contracts/.../types.gen` when it exists, and do not hand-write duplicate response shapes or wrap generated types in local aliases just to preserve an older helper name. Keep local E2E types only for scenario state, fixture registries, helper input options, and intentionally narrowed test view models that are not complete API responses. - -### Console API and protocol boundaries - -The action under test belongs to the browser. `When` steps must use Playwright to perform the user action; do not replace the action with an API request. `Given` setup, seed preparation, persistence polling, and `After` cleanup may use APIs when that makes the scenario faster and more deterministic. `Then` should prefer a user-observable browser result; an API read is appropriate only when persistence itself is the asserted contract and the endpoint owns that state. - -For ordinary Console JSON operations and multipart uploads represented by Console OpenAPI, use the generated oRPC router with generated request and response validation enabled. A scenario client belongs to its `DifyWorld` and uses a scenario-owned authenticated request context that is independent from the behavior browser; seed processes own a standalone client for their process lifetime. Do not create a mutable cross-scenario API client, add TanStack Query caching to Cucumber, hand-write Console endpoint URLs, cast response JSON to an API DTO, or duplicate a generated Zod schema. When a browser action's captured response must provide an ID for cleanup, parse it with the generated response schema. - -Do not add a helper that only renames or forwards one generated operation. Call the generated client directly from the owning step, hook, or fixture orchestration. Keep a helper only when it owns a real test concern such as constructing a valid domain fixture, coordinating multiple operations, maintaining an invariant or cleanup registry, polling eventual consistency, deriving a narrowed test view, or adapting a non-OpenAPI protocol. - -SSE/event streams, binary downloads, redirect-only flows, external services, and infrastructure health/readiness checks may use a dedicated protocol adapter. Keep each exception centralized under its real owner and continue to use generated payload types where the contract covers the request. Multipart is not an exception merely because it carries a file: fix the backend OpenAPI schema and regenerate when the operation can be represented. - -Request or response validation failures are contract failures. Do not suppress them with casts, permissive fallback schemas, disabled validation, swallowed cleanup errors, or a second handwritten request path. Trace the mismatch to the endpoint's backend schema owner, update it according to `api/controllers/API_SCHEMA_GUIDE.md`, regenerate `@dify/contracts`, and keep the E2E assertion aligned with the product's real state owner rather than an internal backing resource. - -Use typed cleanup fields on `DifyWorld` for resource types created by scenarios, and use `DifyWorld.registerCleanup(...)` when a scenario creates any resource type that is not covered by typed cleanup fields. Typed cleanup should remove child or referencing resources before their owners, such as Agent files before Agents and workflow apps before Agents they reference. Cleanup failures should be attached to the report instead of being swallowed silently. Cleanup callbacks run after typed cleanup queues, even when the scenario fails. - -Scenario-owned setup may create disposable apps, Agents, files, credentials, drafts, or access toggles when the scenario owns their lifecycle and cleanup. Do not use scenario setup to silently fix a shared fixture; a missing or drifted fixed resource is a seed failure. - -Feature-specific seed contracts, resource readiness rules, tags, and scenario ownership can be documented in one scoped `AGENTS.md` at the feature root when a module becomes large enough to need it. Do not add deeper `AGENTS.md` files unless the nested module becomes independently owned. - -## Reusing existing steps - -Before writing a new step definition, inspect the existing step definition files first. Reuse a matching step when the wording and behavior already fit, and only add a new step when the scenario needs a genuinely new user action or assertion. Steps in `common/` are designed for broad reuse across all features. - -Or browse the step definition files directly: - -- `features/step-definitions/common/` — auth guards and navigation assertions shared by all features -- `features/step-definitions//` — domain-specific steps scoped to a single feature area +Failures produce screenshots and HTML captures under `cucumber-report/artifacts/`; the HTML and Cucumber Messages reports live under `cucumber-report/`. Backend and frontend startup logs live under `.logs/`. Additional CI lanes preserve their own report and log directories. diff --git a/packages/dify-ui/AGENTS.md b/packages/dify-ui/AGENTS.md index d37f44e934d..255d56f6bed 100644 --- a/packages/dify-ui/AGENTS.md +++ b/packages/dify-ui/AGENTS.md @@ -1,91 +1,31 @@ # @langgenius/dify-ui -Shared design tokens, the `cn()` utility, CSS-first Tailwind styles, and headless primitive components consumed by `web/`. +This package owns shared design tokens, CSS-first Tailwind styles, the `cn()` utility, and headless primitives consumed by `web/`. Read only the matching topic in [`README.md`] for public imports, forms, typed values, pickers, overlays, tokens, or tests. ## Component Authoring Rules -- Use `@base-ui/react` primitives + `cva` + `cn`. -- Inside dify-ui, cross-component imports use relative paths (`../button`). External consumers use subpath exports (`@langgenius/dify-ui/button`). -- No imports from `web/`. No dependencies on next / i18next / ky / jotai / zustand. -- One component per folder: `src//index.tsx`, optional `index.stories.tsx` and `__tests__/index.spec.tsx`. Add a matching `./` subpath to `package.json#exports`. -- Name the canonical public boundary and its associated public types after the primitive without a `Root` suffix (`Select` / `SelectProps`). Keep `Root` only when the subpath also exports a higher-level convenience component that must be distinguished from the low-level anatomy root (`CheckboxRoot` / `Checkbox`). Preserve the upstream anatomy in implementation type sources such as `BaseSelect.Root.Props`. -- Props pattern: `Omit & VariantProps & { /* custom */ }`. -- Use plain `Omit<...>` only for non-union Base UI props. When a prop changes the valid shape of related props (for example `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`), model that relationship with an explicit discriminated union or a distributive helper instead of flattening the props. -- Preserve Base UI generic value contracts in wrappers. If the upstream primitive is generic, expose the same generic parameters and pass them through to the Base UI part, such as `Select.Root`, `RadioGroup`, or `Radio.Root`. -- Do not hard-code selection wrappers to `string` unless the upstream primitive is string-only. Select and radio wrappers that carry selected values should preserve the primitive's generic value contract. -- When a component accepts a prop typed from a shared internal module, `export type` it from that component so consumers import it from the component subpath. -- Prefer Base UI data attributes and CSS variables for visual states; do not mirror state in React solely to add classes. -- When a Base UI API or selector contract is unclear, read the docs linked from `README.md` and the local `@base-ui/react` `.d.ts` files before coding. +- Build primitives from `@base-ui/react`, `cva`, and `cn`. +- Use relative cross-component imports inside the package and subpath exports such as `@langgenius/dify-ui/button` from consumers. Add a matching `package.json#exports` entry for each public primitive. +- Keep one primitive per `src//` folder with optional colocated stories and tests. +- Do not import from `web/` or depend on Next.js, i18n, application state, or data-fetching libraries. +- Preserve upstream Base UI anatomy and generic value contracts. Use discriminated unions when one prop changes the valid shape of related props; do not flatten those relationships or hard-code selectable values to `string`. +- Export shared public types from the owning component subpath. +- Prefer Base UI data attributes and CSS variables for visual states; do not mirror primitive state in React solely to add classes. +- When a Base UI API or selector contract is unclear, read the current official documentation and local `@base-ui/react` type declarations before coding. -## Overlay Primitive Selection: Tooltip vs PreviewCard vs Popover +Use the README sections as the detailed owners: -Pick by the **trigger's purpose** and **a11y reach**, not visual richness. +- [Imports and public boundaries] +- [Typed value contracts] +- [Search and picker selection] +- [Tailwind and Figma radius mapping] +- [Overlay and portal contracts] +- [Development and test boundaries] -| Primitive | Opens on | Trigger's purpose | Content | Reachable on touch / SR? | -| ------------- | --------------------- | --------------------- | ------------------------- | ------------------------ | -| `Tooltip` | hover / focus | has its own action | short plain-text label | ❌ (label only) | -| `PreviewCard` | hover / focus | navigate through link | link destination preview | ❌ (visual enhancement) | -| `Popover` | click / tap (+ hover) | **open the popup** | anything, incl. long text | ✅ | - -Base UI decision rule ([docs]): - -> _"If the trigger's purpose is to open the popup itself, it's a popover. -> If the trigger's purpose is unrelated to opening the popup, it's a tooltip."_ - -Apply this first, then narrow: - -- `Tooltip` — ephemeral visual label. Trigger must already carry its own `aria-label` / visible text; tooltip mirrors it for sighted mouse/keyboard users. No interactive UI, no multi-line prose. Not dwell-able. -- `PreviewCard` — a visual enhancement for a link that previews its destination. Prefer the canonical anchor trigger and keep the popup non-interactive. Do not place unique or essential information or actions in the popup unless they are also available at the linked destination; touch and screen reader users cannot access the preview. If opening the popup is itself the trigger's purpose, or its content must be accessible across input modes, use `Popover` instead. -- `Popover` — any popup with its own interactions, or any "infotip" (`?` / `(i)` glyph whose sole purpose is to reveal help text). Pass `openOnHover` on `PopoverTrigger` for the infotip case — unlike `Tooltip` / `PreviewCard`, this stays accessible to touch and SR users because the popover still opens on tap and focus. - -Product-level polymorphic trigger compositions are local feature decisions. Document them in the owning feature and do not broaden or weaken the shared primitive contract to match one business workflow. - -## Border Radius: Figma Token → Tailwind Class Mapping - -The Figma design system uses `--radius/*` tokens whose scale is **offset by one step** from Tailwind CSS v4 defaults. When translating Figma specs to code, always use this mapping — never use `radius-*` as a CSS class, and never extend `borderRadius` in the preset. - -| Figma Token | Value | Tailwind Class | -| --------------- | ----- | ---------------- | -| `--radius/2xs` | 2px | `rounded-xs` | -| `--radius/xs` | 4px | `rounded-sm` | -| `--radius/sm` | 6px | `rounded-md` | -| `--radius/md` | 8px | `rounded-lg` | -| `--radius/lg` | 10px | `rounded-[10px]` | -| `--radius/xl` | 12px | `rounded-xl` | -| `--radius/2xl` | 16px | `rounded-2xl` | -| `--radius/3xl` | 20px | `rounded-[20px]` | -| `--radius/6xl` | 28px | `rounded-[28px]` | -| `--radius/full` | 999px | `rounded-full` | - -### Rules - -- **Do not** add custom `borderRadius` theme values. We use Tailwind v4 defaults and arbitrary values (`rounded-[Npx]`) for sizes without a standard equivalent. -- **Do not** use `radius-*` as CSS class names. The old `@utility radius-*` definitions have been removed. -- When the Figma MCP returns `rounded-[var(--radius/sm, 6px)]`, convert it to the standard Tailwind class from the table above (e.g. `rounded-md`). -- For values without a standard Tailwind equivalent (10px, 20px, 28px), use arbitrary values like `rounded-[10px]`. - -## Search / Picker Primitive Selection: Autocomplete vs Combobox vs Select - -Pick by whether the user is entering free-form text, choosing a remembered value, or selecting from a closed list. - -Base UI decision rules: - -- [Autocomplete docs]: use `Combobox` instead of `Autocomplete` if the selection should be remembered and the input value cannot be custom. -- [Combobox docs]: do not use `Combobox` for simple search widgets that require unrestricted text entry; use `Autocomplete` instead. - -Apply this split in Dify UI: - -- `Autocomplete` — free-form text input with optional suggestions or completions. The input value may be custom and does not necessarily become a selected option. Use for search boxes, command-style suggestions, tag suggestions, and async text completion. -- `Combobox` — searchable picker whose value is one or more selected items from a collection. The chosen value is remembered by the root, and free-form text is not the final value. Use for model pickers, user pickers, dataset/document pickers, and multi-select chips. -- `Select` — closed-list picker without text entry. Use when the option set is small or already scannable and filtering is unnecessary. - -Composition rules: - -- Keep Base UI primitive semantics visible in the public API. Export compound parts such as `ComboboxInputGroup`, `ComboboxInput`, `ComboboxContent`, `ComboboxList`, `ComboboxItem`, and `ComboboxItemIndicator` instead of wrapping them into one business component. -- For `Combobox` multiple selection, follow the official chips pattern: `ComboboxInputGroup` contains `ComboboxChips`, `ComboboxValue` renders `ComboboxChip` items, and `ComboboxInput` remains inside the chips row. Chips should wrap and let the input group grow vertically instead of forcing horizontal overflow. -- Content primitives must own their Base UI `Portal` and use `z-50` on `Positioner`, matching the overlay contract in `README.md`. Toast owns `z-60`. -- Use `w-(--anchor-width)` with viewport-aware max-width for `Autocomplete` and `Combobox` popups. Do not add `min-w-(--anchor-width)` when it would defeat available-width clamping. - -[Autocomplete docs]: https://base-ui.com/react/components/autocomplete.md#usage-guidelines -[Combobox docs]: https://base-ui.com/react/components/combobox.md#usage-guidelines -[docs]: https://base-ui.com/react/components/tooltip#infotips +[Development and test boundaries]: README.md#development +[Imports and public boundaries]: README.md#imports +[Overlay and portal contracts]: README.md#overlay--portal-contract +[Search and picker selection]: README.md#search-and-picker-selection +[Tailwind and Figma radius mapping]: README.md#tailwind-css-v4-integration +[Typed value contracts]: README.md#typed-value-contracts +[`README.md`]: README.md diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 6345c4e010e..a39f4144768 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -129,6 +129,16 @@ For complex business forms, keep state ownership outside these primitives. TanSt Migration rule for `web/`: if a UI has a save/submit action, do not leave it as unrelated `Input` and `Button` pieces. Give it a real submit boundary with `Form` or a native `
`, attach visible field names through the appropriate label primitive (`FieldLabel`, `SelectLabel`, `SliderLabel`, or `FieldsetLegend`), expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`. +## Search and picker selection + +Choose the primitive by its value contract: + +- `Autocomplete` accepts free-form text with optional suggestions or completions. +- `Combobox` selects and remembers one or more values from a searchable collection. +- `Select` chooses from a closed, scannable list without text entry. + +Keep Base UI anatomy visible in public APIs instead of wrapping a picker into one business component. Multiple-selection comboboxes follow the official chips composition: chips and input share the input group, chips wrap, and the group grows vertically. Autocomplete and Combobox popups own their portals, use the package overlay layer, and size from `--anchor-width` with viewport-aware maximum width; do not force a minimum width that defeats viewport clamping. + ## Tailwind CSS v4 integration This package uses Tailwind CSS v4's CSS-first configuration model. Consumers should import Tailwind from their own root stylesheet, then import this package's CSS entry: @@ -144,6 +154,23 @@ If a consumer uses Dify UI source files through the workspace, add an explicit s @source '../packages/dify-ui/src'; ``` +Figma radius tokens are offset by one step from Tailwind CSS v4 defaults. Use this mapping rather than adding custom theme values or `radius-*` utilities: + +| Figma token | Tailwind class | +| --------------- | ---------------- | +| `--radius/2xs` | `rounded-xs` | +| `--radius/xs` | `rounded-sm` | +| `--radius/sm` | `rounded-md` | +| `--radius/md` | `rounded-lg` | +| `--radius/lg` | `rounded-[10px]` | +| `--radius/xl` | `rounded-xl` | +| `--radius/2xl` | `rounded-2xl` | +| `--radius/3xl` | `rounded-[20px]` | +| `--radius/6xl` | `rounded-[28px]` | +| `--radius/full` | `rounded-full` | + +Convert Figma output such as `rounded-[var(--radius/sm, 6px)]` to the mapped Tailwind class. Use an arbitrary value only when no standard class matches. + ## Overlay & portal contract Overlay primitives render their floating surfaces inside a [Base UI Portal] attached to `document.body`. This is the Base UI default — see the upstream [Portals][Base UI Portal] docs for the underlying behavior. Convenience content components such as `DialogContent`, `PopoverContent`, and `SelectContent` own their portal internally; primitives with explicit portal anatomy such as `Drawer` expose the matching `DrawerPortal` part so consumers can compose the full Base UI structure. @@ -189,7 +216,7 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b ## Development -- `vp run @langgenius/dify-ui#lint` (from the repository root) — strict Oxlint checks for component source, stories, tests, and package configuration. +- `vp check packages/dify-ui` (from the repository root) — formatting, lint, and TypeScript diagnostics for the package. - `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives. - `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`. - `pnpm -C packages/dify-ui test:storybook` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates. @@ -228,7 +255,6 @@ Set the Base UI test flag in a Vitest setup file to skip those waits: See `[AGENTS.md](./AGENTS.md)` for: - Component authoring rules (one-component-per-folder, `cva` + `cn`, relative imports inside the package, subpath imports from consumers). -- Figma `--radius/`_ token → Tailwind `rounded-_` class mapping. ## Not part of this package diff --git a/web/AGENTS.md b/web/AGENTS.md index abce2ee1352..4ef0f64bb39 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,53 +1,14 @@ ## Frontend Workflow -- Refer to the `./docs/test.md` and `./docs/lint.md` for detailed frontend workflow instructions. -- For frontend coding tasks, also apply the repo-local `how-to-write-component` skill when the change touches React components, state ownership, routing, styling, or Tailwind classes. -- For frontend reviews, use the repo-local `frontend-code-review` skill as the canonical checklist. +- Read `docs/test.md` only for frontend test work and `docs/lint.md` only when running or changing static checks. +- Use the repo-local `how-to-write-component` skill when implementation requires component ownership, state, data-flow, effect, or interaction-boundary decisions. Do not load it for test-only, copy-only, or styling-only changes. +- Use `frontend-code-review` only for explicit frontend review or audit requests, including test reviews. Use `frontend-testing` when writing or changing Vitest or React Testing Library tests. -## i18n +## Package Contracts -- User-facing strings must use `web/i18n/en-US/` keys instead of hardcoded text. -- When adding or renaming an i18n key, update all supported locale files with correct localized values. Do not leave fallback English in non-English locales unless the repo already intentionally does so for that exact key. - -## Backend API Calls - -- For new backend calls, and for surfaces already migrated to generated contracts, use `consoleQuery` / `consoleClient` from `@/service/client`. Do not add handwritten REST helpers, handwritten API types, mock-backed app state, or direct edits to generated contract files. - -## Overlay Components (Mandatory) - -- `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-50` / `z-60` layering. -- `./docs/overlay.md` records the current web overlay best practices. -- In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`. -- Do not introduce overlay imports from `@/app/components/base/*`; when touching existing callers, migrate them. - -## UI Components - -- Use `@langgenius/dify-ui/*` primitives and primitive data/CSS selectors first. Add call-site Tailwind only for real design deltas, avoid arbitrary values when token utilities exist, and keep focus rings visible without making inert layout regions focusable. - -## SVG Icons (Mandatory) - -- New custom SVG icons must be added under `../packages/iconify-collections/assets/...`. -- Run `pnpm --filter @dify/iconify-collections generate` and consume generated icons with Tailwind `i-custom-*` classes. -- Restart the web dev server after regenerating icons because Tailwind loads the custom icon collection at startup. -- Do not add new generated React icon components or JSON files under `app/components/base/icons/src/...`. -- See `../packages/iconify-collections/README.md` for the full workflow. - -## Design Token Mapping - -- When translating Figma designs to code, read `../packages/dify-ui/AGENTS.md` for the Figma `--radius/*` token to Tailwind `rounded-*` class mapping. The two scales are offset by one step. - -## Client State Management - -- Use local component state for state owned by one component. -- Use feature-level Jotai atoms for simple client state shared across components in the same feature, especially when components need a shared source of truth, derived values, or shared actions. -- Use existing feature stores for complex or high-frequency interaction state such as workflow canvas, drag, resize, and panel runtime state. -- For shared low-frequency, client-only persistence such as user preferences, dismissed notices, and UI defaults, use feature-owned storage modules built with `createLocalStorageState`. -- For high-frequency interactions, update the feature state during interaction and persist storage only on commit or settled updates. -- Keep storage keys and raw/custom formats in the owner module; callers should import the named storage hooks instead of scattering direct storage access. -- Do not add ad hoc global event listeners for shared state. Prefer atoms, existing stores, or a shared subscription hook so listeners are centralized and deduplicated. - -## Frontend Testing - -- `./docs/test.md` is the single source of truth for frontend automated test policy. -- Use the `frontend-testing` skill to apply that policy when writing or reviewing Vitest and React Testing Library tests. The skill must not introduce separate requirements. -- Add tests based on observable behavior and regression risk, not file count, hook usage, or coverage percentages. +- User-facing strings must use `web/i18n/en-US/` keys. When adding or renaming a key, update every supported locale with the correct localized value. +- For new backend calls and migrated surfaces, use generated `consoleQuery` / `consoleClient` APIs from `@/service/client`. Do not add handwritten REST helpers or DTO mirrors, mock-backed app state, or direct edits to generated contracts. +- Prefer `@langgenius/dify-ui/*` primitives, data attributes, and design tokens. Preserve a visible focus indicator on the final focusable element. +- Follow `docs/overlay.md` for overlay selection and migration. Migrate a legacy overlay only when the current behavior change actually involves that overlay boundary. +- For custom SVG icons, follow `../packages/iconify-collections/README.md`; do not add generated React icons under `app/components/base/icons/src/`. +- `docs/test.md` is the single source of truth for frontend automated-test policy. Skills may route and execute that policy but must not redefine it. From 8511a3143ebba860068f9a5fc30f7a3d74c06126 Mon Sep 17 00:00:00 2001 From: SpiliosDmk <150211937+SpiliosDimakopoulos@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:43:25 +0300 Subject: [PATCH 006/531] docs: Broken Grafana link in Turkish README (placeholder text instead of URL) (#39575) --- docs/tr-TR/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tr-TR/README.md b/docs/tr-TR/README.md index 6139fd16ca2..4a245cc7e27 100644 --- a/docs/tr-TR/README.md +++ b/docs/tr-TR/README.md @@ -132,7 +132,7 @@ Yapılandırmayı özelleştirmeniz gerekiyorsa, lütfen [.env.example](../../do Uygulamalar, kiracılar, mesajlar ve daha fazlasının granularitesinde metrikleri izlemek için Dify'nin PostgreSQL veritabanını veri kaynağı olarak kullanarak panoyu Grafana'ya aktarın. -- [@bowenliang123 tarafından Grafana Panosu](%E9%93%BE%E6%8E%A5) +- [@bowenliang123 tarafından Grafana Panosu](https://github.com/bowenliang123/dify-grafana-dashboard) ### Kubernetes ile Dağıtım From 448e378fc03de2b556f819e867297230a73bb1c4 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:08:27 +0800 Subject: [PATCH 007/531] fix(dify-ui): honor instant popup transitions (#39572) --- packages/dify-ui/src/overlay-shared.ts | 2 +- .../src/popover/__tests__/index.spec.tsx | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/dify-ui/src/overlay-shared.ts b/packages/dify-ui/src/overlay-shared.ts index fbeeef80e43..fcd4abc9236 100644 --- a/packages/dify-ui/src/overlay-shared.ts +++ b/packages/dify-ui/src/overlay-shared.ts @@ -11,7 +11,7 @@ export const floatingSeparatorClassName = 'my-1 h-px bg-divider-subtle' export const menuPopupClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]' export const floatingPopupAnimationClassName = - 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' + 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:transition-none motion-reduce:transition-none' export const modalBackdropClassName = 'absolute inset-0 z-50 bg-background-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' export const modalPopupAnimationClassName = diff --git a/packages/dify-ui/src/popover/__tests__/index.spec.tsx b/packages/dify-ui/src/popover/__tests__/index.spec.tsx index 23351eb667e..19593668878 100644 --- a/packages/dify-ui/src/popover/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/popover/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ import type * as React from 'react' +import { userEvent } from 'vite-plus/test/browser' import { render } from 'vitest-browser-react' import { Popover, PopoverContent, PopoverTrigger } from '..' @@ -6,6 +7,43 @@ const renderWithSafeViewport = (ui: React.ReactNode) => render(
{ui}
) describe('PopoverContent', () => { + describe('Animation', () => { + it('should restore focus without waiting for an instant close transition', async () => { + const animationSettings = globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } + const animationsDisabled = animationSettings.BASE_UI_ANIMATIONS_DISABLED + animationSettings.BASE_UI_ANIMATIONS_DISABLED = false + + try { + const screen = await renderWithSafeViewport( + + Open + + + + , + ) + + const trigger = screen.getByRole('button', { name: 'Open' }) + await trigger.click() + + const focusableContent = screen.getByRole('button', { name: 'Focusable content' }) + focusableContent.element().focus() + await expect.element(focusableContent).toHaveFocus() + + await userEvent.keyboard('{Escape}') + + await expect.element(trigger).toHaveFocus() + } finally { + animationSettings.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled + } + }) + }) + describe('Placement', () => { it('should use bottom placement and default offsets when placement props are not provided', async () => { const screen = await renderWithSafeViewport( From 99c1cb77815be5232af989ff22344595f950b863 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:08:48 +0800 Subject: [PATCH 008/531] fix(web): prevent search hotkey hydration mismatch (#39574) --- .../__tests__/search-button.spec.tsx | 34 +++++++++++++++++++ .../main-nav/components/search-button.tsx | 22 ++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 web/app/components/main-nav/components/__tests__/search-button.spec.tsx diff --git a/web/app/components/main-nav/components/__tests__/search-button.spec.tsx b/web/app/components/main-nav/components/__tests__/search-button.spec.tsx new file mode 100644 index 00000000000..0e8d74c1a72 --- /dev/null +++ b/web/app/components/main-nav/components/__tests__/search-button.spec.tsx @@ -0,0 +1,34 @@ +import { act, waitFor, within } from '@testing-library/react' +import { hydrateRoot } from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { MainNavSearchButton } from '../search-button' + +describe('MainNavSearchButton', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders Ctrl during SSR and corrects it after Mac hydration without a mismatch', async () => { + vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('MacIntel') + const app = + const container = document.createElement('div') + container.innerHTML = renderToString(app) + const getSearchButton = () => + within(container).getByRole('button', { name: 'app.gotoAnything.searchTitle' }) + + expect(getSearchButton()).toHaveTextContent('CtrlK') + expect(getSearchButton()).not.toHaveTextContent('⌘') + + const onRecoverableError = vi.fn() + const root = hydrateRoot(container, app, { + onRecoverableError, + }) + + try { + await waitFor(() => expect(getSearchButton()).toHaveTextContent('⌘K')) + expect(onRecoverableError).not.toHaveBeenCalled() + } finally { + act(() => root.unmount()) + } + }) +}) diff --git a/web/app/components/main-nav/components/search-button.tsx b/web/app/components/main-nav/components/search-button.tsx index 1539d38369f..c8378e1f79c 100644 --- a/web/app/components/main-nav/components/search-button.tsx +++ b/web/app/components/main-nav/components/search-button.tsx @@ -2,13 +2,31 @@ import { DialogTrigger } from '@langgenius/dify-ui/dialog' import { Kbd } from '@langgenius/dify-ui/kbd' -import { formatForDisplay } from '@tanstack/react-hotkeys' +import { detectPlatform, formatForDisplay } from '@tanstack/react-hotkeys' +import { useSyncExternalStore } from 'react' import { useTranslation } from 'react-i18next' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { GOTO_ANYTHING_HOTKEY } from '@/app/components/goto-anything/hotkeys' +function noopSubscribe() { + return () => {} +} + +function getPlatformSnapshot() { + return detectPlatform() +} + +function getServerPlatformSnapshot(): ReturnType { + return 'linux' +} + +function useDisplayPlatform() { + return useSyncExternalStore(noopSubscribe, getPlatformSnapshot, getServerPlatformSnapshot) +} + export function MainNavSearchButton() { const { t } = useTranslation() + const displayPlatform = useDisplayPlatform() return ( {GOTO_ANYTHING_HOTKEY.split('+').map((key) => ( - {formatForDisplay(key)} + {formatForDisplay(key, { platform: displayPlatform })} ))} From c61d33f91e32278881514ad77182ca167cd85b1a Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:02:30 +0800 Subject: [PATCH 009/531] refactor: move trial app capability out of system features (#39562) --- .../console/explore/recommended_app.py | 21 +-- api/controllers/console/explore/trial.py | 10 +- api/controllers/console/explore/wraps.py | 5 +- api/openapi/markdown/console-openapi.md | 5 +- api/openapi/markdown/web-openapi.md | 1 - api/services/feature_service.py | 2 - api/services/recommended_app_service.py | 41 +++-- .../commands/test_generate_swagger_specs.py | 9 +- .../console/explore/test_recommended_app.py | 17 ++- .../controllers/console/explore/test_trial.py | 6 +- .../controllers/console/explore/test_wraps.py | 20 +-- .../services/test_recommended_app_service.py | 142 ++++++++---------- .../api/console/explore/types.gen.ts | 6 +- .../generated/api/console/explore/zod.gen.ts | 6 +- .../api/console/system-features/types.gen.ts | 1 - .../api/console/system-features/zod.gen.ts | 1 - .../contracts/generated/api/web/types.gen.ts | 1 - .../contracts/generated/api/web/zod.gen.ts | 1 - .../components/apps/__tests__/index.spec.tsx | 11 ++ web/app/components/apps/index.tsx | 8 +- web/app/components/explore/app-list/index.tsx | 9 +- .../explore/try-app/__tests__/index.spec.tsx | 39 ++++- web/app/components/explore/try-app/index.tsx | 41 ++--- web/service/explore.spec.ts | 1 + web/service/explore.ts | 4 +- web/test/console/system-features.ts | 1 - 26 files changed, 220 insertions(+), 189 deletions(-) diff --git a/api/controllers/console/explore/recommended_app.py b/api/controllers/console/explore/recommended_app.py index 79eaa305d61..af46cdc8c9b 100644 --- a/api/controllers/console/explore/recommended_app.py +++ b/api/controllers/console/explore/recommended_app.py @@ -11,7 +11,7 @@ from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, with_current_user from extensions.ext_database import db from fields.base import ResponseModel -from libs.helper import build_icon_url +from libs.helper import build_icon_url, dump_response from libs.login import login_required from models import Account from services.recommended_app_service import RecommendedAppService @@ -58,7 +58,7 @@ class RecommendedAppResponse(ResponseModel): categories: list[str] = Field(default_factory=list) position: int | None = None is_listed: bool | None = None - can_trial: bool | None = None + can_trial: bool class RecommendedAppListResponse(ResponseModel): @@ -77,7 +77,7 @@ class RecommendedAppDetailResponse(ResponseModel): icon_background: str | None = None mode: str export_data: str - can_trial: bool | None = None + can_trial: bool class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]): @@ -119,10 +119,10 @@ class RecommendedAppListApi(Resource): args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) language_prefix = _resolve_language(args.language, current_user) - return RecommendedAppListResponse.model_validate( + return dump_response( + RecommendedAppListResponse, RecommendedAppService.get_recommended_apps_and_categories(language_prefix, session=db.session()), - from_attributes=True, - ).model_dump(mode="json") + ) @console_ns.route("/explore/apps/learn-dify") @@ -136,10 +136,10 @@ class LearnDifyAppListApi(Resource): args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True)) language_prefix = _resolve_language(args.language, current_user) - return LearnDifyAppListResponse.model_validate( + return dump_response( + LearnDifyAppListResponse, RecommendedAppService.get_learn_dify_apps(language_prefix, session=db.session()), - from_attributes=True, - ).model_dump(mode="json") + ) @console_ns.route("/explore/apps/") @@ -148,4 +148,5 @@ class RecommendedAppApi(Resource): @login_required @account_initialization_required def get(self, app_id: UUID): - return RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session()) + result = RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session()) + return RecommendedAppDetailNullableResponse.model_validate(result).model_dump(mode="json") diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 67ff9708959..553e65202ce 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -46,7 +46,7 @@ from controllers.console.explore.error import ( NotCompletionAppError, NotWorkflowAppError, ) -from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable +from controllers.console.explore.wraps import TrialAppResource from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request from controllers.console.wraps import cloud_edition_billing_resource_check, with_current_user @@ -432,7 +432,6 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__] class TrialAppFileUploadApi(TrialAppResource): - @trial_feature_enable @cloud_edition_billing_resource_check("documents") @console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS) @console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__]) @@ -447,7 +446,6 @@ class TrialAppFileUploadApi(TrialAppResource): class TrialAppRemoteFileUploadApi(TrialAppResource): - @trial_feature_enable @cloud_edition_billing_resource_check("documents") @console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__]) @console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__]) @@ -462,7 +460,6 @@ class TrialAppRemoteFileUploadApi(TrialAppResource): class TrialAppWorkflowRunApi(TrialAppResource): - @trial_feature_enable @console_ns.expect(console_ns.models[WorkflowRunRequest.__name__]) @console_ns.response(200, "Success") @with_current_user @@ -513,7 +510,6 @@ class TrialAppWorkflowRunApi(TrialAppResource): class TrialAppWorkflowTaskStopApi(TrialAppResource): @console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__]) - @trial_feature_enable def post(self, trial_app, task_id: str): """ Stop workflow task @@ -538,7 +534,6 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource): class TrialChatApi(TrialAppResource): @console_ns.expect(console_ns.models[ChatRequest.__name__]) @console_ns.response(200, "Success") - @trial_feature_enable @with_current_user @with_session def post(self, session: Session, current_user: Account, trial_app): @@ -640,7 +635,6 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource): class TrialChatAudioApi(TrialAppResource): @console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__]) - @trial_feature_enable @with_current_user def post(self, current_user: Account, trial_app): app_model = trial_app @@ -691,7 +685,6 @@ class TrialChatAudioApi(TrialAppResource): class TrialChatTextApi(TrialAppResource): @console_ns.expect(console_ns.models[TextToSpeechRequest.__name__]) @console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__]) - @trial_feature_enable @with_current_user def post(self, current_user: Account, trial_app): app_model = trial_app @@ -752,7 +745,6 @@ class TrialChatTextApi(TrialAppResource): class TrialCompletionApi(TrialAppResource): @console_ns.expect(console_ns.models[CompletionRequest.__name__]) @console_ns.response(200, "Success") - @trial_feature_enable @with_current_user @with_session def post(self, session: Session, current_user: Account, trial_app): diff --git a/api/controllers/console/explore/wraps.py b/api/controllers/console/explore/wraps.py index d67f3e18d53..01234172849 100644 --- a/api/controllers/console/explore/wraps.py +++ b/api/controllers/console/explore/wraps.py @@ -14,6 +14,7 @@ from libs.login import current_account_with_tenant, login_required from models import AccountTrialAppRecord, App, InstalledApp, TrialApp from services.enterprise.enterprise_service import EnterpriseService from services.feature_service import FeatureService +from services.recommended_app_service import RecommendedAppService def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None): @@ -106,8 +107,7 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N def trial_feature_enable[**P, R](view: Callable[P, R]): @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - features = FeatureService.get_system_features() - if not features.enable_trial_app: + if not RecommendedAppService.is_trial_app_enabled(): abort(403, "Trial app feature is not enabled.") return view(*args, **kwargs) @@ -141,6 +141,7 @@ class TrialAppResource(Resource): method_decorators = [ trial_app_required, + trial_feature_enable, account_initialization_required, login_required, ] diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index ef38f3c891d..367ba473e9e 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -21028,7 +21028,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| can_trial | boolean | | No | +| can_trial | boolean | | Yes | | export_data | string | | Yes | | icon | string | | No | | icon_background | string | | No | @@ -21061,7 +21061,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs. | ---- | ---- | ----------- | -------- | | app | [RecommendedAppInfoResponse](#recommendedappinforesponse) | | No | | app_id | string | | Yes | -| can_trial | boolean | | No | +| can_trial | boolean | | Yes | | categories | [ string ] | | No | | copyright | string | | No | | custom_disclaimer | string | | No | @@ -22058,7 +22058,6 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication. | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | | enable_step_by_step_tour | boolean | | Yes | -| enable_trial_app | boolean | | Yes | | is_allow_register | boolean | | Yes | | is_email_setup | boolean | | Yes | | knowledge_fs_enabled | boolean | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 2ba8c35dd17..09c6842329c 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1557,7 +1557,6 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication. | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | | enable_step_by_step_tour | boolean | | Yes | -| enable_trial_app | boolean | | Yes | | is_allow_register | boolean | | Yes | | is_email_setup | boolean | | Yes | | knowledge_fs_enabled | boolean | | Yes | diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 6225562ec25..d80d0344788 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -181,7 +181,6 @@ class SystemFeatureModel(FeatureResponseModel): plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel() enable_change_email: bool = True enable_creators_platform: bool = False - enable_trial_app: bool = False enable_explore_banner: bool = False enable_learn_app: bool = True enable_step_by_step_tour: bool = False @@ -310,7 +309,6 @@ class FeatureService: system_features.is_allow_register = dify_config.ALLOW_REGISTER system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != "" system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL - system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index 3bdfbe6f365..8c9194e731d 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -4,12 +4,19 @@ from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config +from enums.deployment_edition import DeploymentEdition from models.model import AccountTrialAppRecord, App, TrialApp -from services.feature_service import FeatureService from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory class RecommendedAppService: + """Own recommended app retrieval and Cloud-only trial eligibility.""" + + @staticmethod + def is_trial_app_enabled() -> bool: + """Return whether trial execution is enabled for this deployment.""" + return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP + @classmethod def get_app(cls, app_id: str, *, session: Session) -> App | None: """Return a normal app only when it belongs to the recommended catalog.""" @@ -38,11 +45,12 @@ class RecommendedAppService: ) ) - if FeatureService.get_system_features().enable_trial_app: - apps = result["recommended_apps"] - for app in apps: - app_id = app["app_id"] - app["can_trial"] = cls._can_trial_app(session, app_id) + apps = result["recommended_apps"] + trial_app_ids = ( + cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set() + ) + for app in apps: + app["can_trial"] = app["app_id"] in trial_app_ids return result @classmethod @@ -56,11 +64,14 @@ class RecommendedAppService: retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)() result = retrieval_instance.get_learn_dify_apps(language, session=session) - if FeatureService.get_system_features().enable_trial_app: - for app in result["recommended_apps"]: - app["can_trial"] = cls._can_trial_app(session, app["app_id"]) + apps = result["recommended_apps"] + trial_app_ids = ( + cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set() + ) + for app in apps: + app["can_trial"] = app["app_id"] in trial_app_ids - return {"recommended_apps": result["recommended_apps"]} + return {"recommended_apps": apps} @classmethod def get_recommend_app_detail(cls, app_id: str, *, session: Session) -> dict[str, Any] | None: @@ -74,9 +85,7 @@ class RecommendedAppService: result: dict[str, Any] | None = retrieval_instance.get_recommend_app_detail(app_id, session=session) if result is None: return None - if FeatureService.get_system_features().enable_trial_app: - app_id = result["id"] - result["can_trial"] = cls._can_trial_app(session, app_id) + result["can_trial"] = cls.is_trial_app_enabled() and cls._can_trial_app(session, result["id"]) return result @classmethod @@ -102,3 +111,9 @@ class RecommendedAppService: def _can_trial_app(session: Session, app_id: str) -> bool: trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1)) return trial_app_model is not None + + @staticmethod + def _get_trial_app_ids(session: Session, app_ids: list[str]) -> set[str]: + if not app_ids: + return set() + return set(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all()) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index 7ec832c526c..8726067822b 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -115,6 +115,7 @@ def test_system_features_specs_exclude_backend_only_fields(tmp_path): written_paths = module.generate_specs(tmp_path) excluded_fields = { + "enable_trial_app", "is_allow_create_workspace", "max_plugin_package_size", "plugin_manager", @@ -223,7 +224,13 @@ def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp app_detail_schema = schemas["RecommendedAppDetailResponse"] assert app_detail_schema["properties"]["id"]["type"] == "string" assert app_detail_schema["properties"]["export_data"]["type"] == "string" - assert {"type": "boolean"} in app_detail_schema["properties"]["can_trial"]["anyOf"] + assert app_detail_schema["properties"]["can_trial"]["type"] == "boolean" + assert "anyOf" not in app_detail_schema["properties"]["can_trial"] + assert "can_trial" in app_detail_schema["required"] + app_list_item_schema = schemas["RecommendedAppResponse"] + assert app_list_item_schema["properties"]["can_trial"]["type"] == "boolean" + assert "anyOf" not in app_list_item_schema["properties"]["can_trial"] + assert "can_trial" in app_list_item_schema["required"] app_detail_nullable_schema = schemas["RecommendedAppDetailNullableResponse"] assert _response_schema(paths["/explore/apps/{app_id}"]["get"])["$ref"] == ( "#/components/schemas/RecommendedAppDetailNullableResponse" diff --git a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py index 4adeaaa90dd..9c9338ccc43 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py +++ b/api/tests/unit_tests/controllers/console/explore/test_recommended_app.py @@ -1,7 +1,9 @@ from inspect import unwrap from unittest.mock import ANY, patch +import pytest from flask import Flask +from pydantic import ValidationError import controllers.console.explore.recommended_app as module from models import Account @@ -119,7 +121,13 @@ class TestRecommendedAppApi: api = module.RecommendedAppApi() method = unwrap(api.get) - result_data = {"id": "app1"} + result_data = { + "id": "app1", + "name": "App", + "mode": "chat", + "export_data": "{}", + "can_trial": False, + } with ( app.test_request_context("/"), @@ -132,7 +140,7 @@ class TestRecommendedAppApi: result = method(api, "11111111-1111-1111-1111-111111111111") service_mock.assert_called_once_with("11111111-1111-1111-1111-111111111111", session=ANY) - assert result == result_data + assert result == {**result_data, "icon": None, "icon_background": None} class TestRecommendedAppResponseModels: @@ -198,6 +206,7 @@ class TestRecommendedAppResponseModels: "categories": ["Workflow"], "position": 1, "is_listed": True, + "can_trial": False, } ], } @@ -205,3 +214,7 @@ class TestRecommendedAppResponseModels: assert response["recommended_apps"][0]["app_id"] == "app-1" assert response["recommended_apps"][0]["categories"] == ["Workflow"] + + def test_recommended_app_response_requires_can_trial(self): + with pytest.raises(ValidationError): + module.RecommendedAppResponse.model_validate({"app_id": "app-1"}) diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index 426f83d5047..5c2d20c75a1 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -1109,15 +1109,13 @@ class TestTrialChatTextApi: class TestTrialAppWorkflowTaskStopApi: def test_not_workflow_app(self, app: Flask, trial_app_chat: MagicMock) -> None: api = module.TrialAppWorkflowTaskStopApi() - method = unwrap(api.post) with app.test_request_context("/"): with pytest.raises(NotWorkflowAppError): - method(api, trial_app_chat, str(uuid4())) + api.post(trial_app_chat, str(uuid4())) def test_success(self, app: Flask, trial_app_workflow: MagicMock) -> None: api = module.TrialAppWorkflowTaskStopApi() - method = unwrap(api.post) task_id = str(uuid4()) with ( @@ -1125,7 +1123,7 @@ class TestTrialAppWorkflowTaskStopApi: patch.object(module.AppQueueManager, "set_stop_flag_no_user_check") as mock_set_flag, patch.object(module.GraphEngineManager, "send_stop_command") as mock_send_cmd, ): - result = method(api, trial_app_workflow, task_id) + result = api.post(trial_app_workflow, task_id) assert result == {"result": "success"} mock_set_flag.assert_called_once_with(task_id) diff --git a/api/tests/unit_tests/controllers/console/explore/test_wraps.py b/api/tests/unit_tests/controllers/console/explore/test_wraps.py index a60c13315b8..f2eb8523bbf 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/explore/test_wraps.py @@ -255,11 +255,9 @@ def test_trial_feature_enable_disabled(): def view(): return "ok" - features = MagicMock(enable_trial_app=False) - with patch( - "controllers.console.explore.wraps.FeatureService.get_system_features", - return_value=features, + "controllers.console.explore.wraps.RecommendedAppService.is_trial_app_enabled", + return_value=False, ): with pytest.raises(Forbidden): view() @@ -270,11 +268,9 @@ def test_trial_feature_enable_enabled(): def view(): return "ok" - features = MagicMock(enable_trial_app=True) - with patch( - "controllers.console.explore.wraps.FeatureService.get_system_features", - return_value=features, + "controllers.console.explore.wraps.RecommendedAppService.is_trial_app_enabled", + return_value=True, ): assert view() == "ok" @@ -285,5 +281,9 @@ def test_installed_app_resource_decorators(): def test_trial_app_resource_decorators(): - decorators = TrialAppResource.method_decorators - assert len(decorators) == 3 + assert TrialAppResource.method_decorators == [ + trial_app_required, + trial_feature_enable, + wraps_module.account_initialization_required, + wraps_module.login_required, + ] diff --git a/api/tests/unit_tests/services/test_recommended_app_service.py b/api/tests/unit_tests/services/test_recommended_app_service.py index 827ded2d61a..6ebb5b62015 100644 --- a/api/tests/unit_tests/services/test_recommended_app_service.py +++ b/api/tests/unit_tests/services/test_recommended_app_service.py @@ -13,7 +13,6 @@ from sqlalchemy.orm import Session from enums.deployment_edition import DeploymentEdition from models.model import AccountTrialAppRecord, App, AppMode, TrialApp from services import recommended_app_service as service_module -from services.feature_service import SystemFeatureModel from services.recommended_app_service import RecommendedAppService pytestmark = pytest.mark.parametrize( @@ -49,6 +48,30 @@ class AppDetailKwargs(TypedDict, total=False): tools: list[str] +@pytest.mark.parametrize( + ("edition", "enterprise_enabled", "feature_enabled", "expected"), + [ + ("CLOUD", False, True, True), + ("CLOUD", False, False, False), + ("SELF_HOSTED", False, True, False), + ("SELF_HOSTED", True, True, False), + ], +) +def test_trial_app_policy_is_cloud_only( + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + edition: str, + enterprise_enabled: bool, + feature_enabled: bool, + expected: bool, +) -> None: + monkeypatch.setattr(service_module.dify_config, "EDITION", edition) + monkeypatch.setattr(service_module.dify_config, "ENTERPRISE_ENABLED", enterprise_enabled) + monkeypatch.setattr(service_module.dify_config, "ENABLE_TRIAL_APP", feature_enabled) + + assert RecommendedAppService.is_trial_app_enabled() is expected + + # ── Helpers ──────────────────────────────────────────────────────────── @@ -58,8 +81,8 @@ def _apps_response( ) -> AppsResponse: if recommended_apps is None: recommended_apps = [ - {"id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"}, - {"id": "app-2", "name": "Test App 2", "description": "d2", "category": "communication"}, + {"app_id": "app-1", "name": "Test App 1", "description": "d1", "category": "productivity"}, + {"app_id": "app-2", "name": "Test App 2", "description": "d2", "category": "communication"}, ] if categories is None: categories = ["productivity", "communication", "utilities"] @@ -175,7 +198,7 @@ class TestRecommendedAppServiceGetApps: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" empty_response = AppsResponse(recommended_apps=[], categories=[]) builtin_response = _apps_response( - recommended_apps=[{"id": "builtin-1", "name": "Builtin App", "category": "default"}] + recommended_apps=[{"app_id": "builtin-1", "name": "Builtin App", "category": "default"}] ) mock_remote_instance = MagicMock() @@ -189,7 +212,7 @@ class TestRecommendedAppServiceGetApps: result = RecommendedAppService.get_recommended_apps_and_categories("zh-CN", session=sqlite_session) assert result == builtin_response - assert result["recommended_apps"][0]["id"] == "builtin-1" + assert result["recommended_apps"][0]["app_id"] == "builtin-1" mock_builtin_instance.fetch_recommended_apps_from_builtin.assert_called_once_with("en-US") @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @@ -223,7 +246,7 @@ class TestRecommendedAppServiceGetApps: for language in ["en-US", "zh-CN", "ja-JP", "fr-FR"]: lang_response = _apps_response( - recommended_apps=[{"id": f"app-{language}", "name": f"App {language}", "category": "test"}] + recommended_apps=[{"app_id": f"app-{language}", "name": f"App {language}", "category": "test"}] ) mock_instance = MagicMock() mock_instance.get_recommended_apps_and_categories.return_value = lang_response @@ -231,7 +254,7 @@ class TestRecommendedAppServiceGetApps: result = RecommendedAppService.get_recommended_apps_and_categories(language, session=sqlite_session) - assert result["recommended_apps"][0]["id"] == f"app-{language}" + assert result["recommended_apps"][0]["app_id"] == f"app-{language}" mock_instance.get_recommended_apps_and_categories.assert_called_with(language, session=sqlite_session) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @@ -262,14 +285,14 @@ class TestRecommendedAppServiceGetApp: monkeypatch, result=RecommendedAppPayload(id=app.id), ) - feature_lookup = MagicMock(side_effect=AssertionError("get_app must not inspect trial features")) - monkeypatch.setattr(service_module.FeatureService, "get_system_features", feature_lookup) + trial_policy = MagicMock(side_effect=AssertionError("get_app must not inspect trial policy")) + monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", trial_policy) result = RecommendedAppService.get_app(app.id, session=sqlite_session) assert result is app retrieval_instance.get_recommend_app_detail.assert_called_once_with(app.id, session=sqlite_session) - feature_lookup.assert_not_called() + trial_policy.assert_not_called() def test_returns_none_when_app_is_not_recommended( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session @@ -288,21 +311,17 @@ class TestRecommendedAppServiceGetApp: class TestRecommendedAppServiceGetDetail: - @patch("services.recommended_app_service.FeatureService", autospec=True) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") def test_returns_retrieval_detail_when_trial_disabled( self, mock_config: MagicMock, mock_factory_class: MagicMock, - mock_feature_service: MagicMock, sqlite_session: Session, ) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - mock_feature_service.get_system_features.return_value = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_trial_app=False, - ) + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY + mock_config.ENABLE_TRIAL_APP = True cases: list[tuple[str, RecommendedAppPayload]] = [ ( "complex-app", @@ -328,23 +347,20 @@ class TestRecommendedAppServiceGetDetail: result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session) - assert result == expected + assert result is not None + assert result["can_trial"] is False mock_instance.get_recommend_app_detail.assert_called_once_with(app_id, session=sqlite_session) - @patch("services.recommended_app_service.FeatureService", autospec=True) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") def test_different_modes( self, mock_config: MagicMock, mock_factory_class: MagicMock, - mock_feature_service: MagicMock, sqlite_session: Session, ) -> None: - mock_feature_service.get_system_features.return_value = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_trial_app=False, - ) + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY + mock_config.ENABLE_TRIAL_APP = True for mode in ["remote", "builtin", "db"]: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = mode detail = _app_detail(app_id="test-app", name=f"App from {mode}") @@ -363,21 +379,17 @@ class TestRecommendedAppServiceGetDetail: class TestRecommendedAppServiceGetLearnDifyApps: - @patch("services.recommended_app_service.FeatureService", autospec=True) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") def test_uses_configured_retrieval_source( self, mock_config: MagicMock, mock_factory_class: MagicMock, - mock_feature_service: MagicMock, sqlite_session: Session, ) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" - mock_feature_service.get_system_features.return_value = SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_trial_app=False, - ) + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY + mock_config.ENABLE_TRIAL_APP = True expected_app = RecommendedAppPayload(app_id="app-1", category="Workflow") mock_instance = MagicMock() mock_instance.get_learn_dify_apps.return_value = { @@ -388,7 +400,7 @@ class TestRecommendedAppServiceGetLearnDifyApps: result = RecommendedAppService.get_learn_dify_apps("en-US", session=sqlite_session) - assert result == {"recommended_apps": [expected_app]} + assert result == {"recommended_apps": [{**expected_app, "can_trial": False}]} mock_factory_class.get_recommend_app_factory.assert_called_once_with("remote") mock_instance.get_learn_dify_apps.assert_called_once_with("en-US", session=sqlite_session) @@ -409,23 +421,14 @@ class TestRecommendedAppServiceGetLearnDifyApps: "get_recommend_app_factory", MagicMock(return_value=mock_retrieval_factory), ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock( - return_value=SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_trial_app=True, - ) - ), - ) - can_trial_mock = MagicMock(return_value=True) - monkeypatch.setattr(RecommendedAppService, "_can_trial_app", can_trial_mock) + monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True)) + trial_app_ids = MagicMock(return_value={"app-1"}) + monkeypatch.setattr(RecommendedAppService, "_get_trial_app_ids", trial_app_ids) result = RecommendedAppService.get_learn_dify_apps("en-US", session=sqlite_session) assert result["recommended_apps"][0]["can_trial"] is True - can_trial_mock.assert_called_once_with(sqlite_session, "app-1") + trial_app_ids.assert_called_once_with(sqlite_session, ["app-1"]) # ── Integration tests: trial app features (real DB) ──────────────────── @@ -435,22 +438,20 @@ class TestRecommendedAppServiceTrialFeatures: def test_get_apps_should_not_query_trial_table_when_disabled( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: - expected = AppsResponse(recommended_apps=[RecommendedAppPayload(app_id="app-1")], categories=["all"]) - retrieval_instance, builtin_instance = _mock_factory_for_apps(monkeypatch, mode="remote", result=expected) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock( - return_value=SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_trial_app=False, - ) - ), + upstream_result = AppsResponse( + recommended_apps=[RecommendedAppPayload(app_id="app-1", can_trial=True)], categories=["all"] ) + retrieval_instance, builtin_instance = _mock_factory_for_apps( + monkeypatch, mode="remote", result=upstream_result + ) + monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=False)) + trial_app_ids = MagicMock(side_effect=AssertionError("disabled trial must not query TrialApp")) + monkeypatch.setattr(RecommendedAppService, "_get_trial_app_ids", trial_app_ids) result = RecommendedAppService.get_recommended_apps_and_categories("en-US", session=sqlite_session) - assert result == expected + assert result["recommended_apps"][0]["can_trial"] is False + trial_app_ids.assert_not_called() retrieval_instance.get_recommended_apps_and_categories.assert_called_once_with("en-US", session=sqlite_session) builtin_instance.fetch_recommended_apps_from_builtin.assert_not_called() @@ -473,16 +474,7 @@ class TestRecommendedAppServiceTrialFeatures: _, builtin_instance = _mock_factory_for_apps( monkeypatch, mode="remote", result=remote_result, fallback_result=fallback_result ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock( - return_value=SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_trial_app=True, - ) - ), - ) + monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True)) result = RecommendedAppService.get_recommended_apps_and_categories("ja-JP", session=sqlite_session) @@ -514,16 +506,7 @@ class TestRecommendedAppServiceTrialFeatures: "get_recommend_app_factory", MagicMock(return_value=retrieval_factory), ) - monkeypatch.setattr( - service_module.FeatureService, - "get_system_features", - MagicMock( - return_value=SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - enable_trial_app=True, - ) - ), - ) + monkeypatch.setattr(RecommendedAppService, "is_trial_app_enabled", MagicMock(return_value=True)) result = RecommendedAppService.get_recommend_app_detail(app_id, session=sqlite_session) assert result is not None @@ -532,26 +515,25 @@ class TestRecommendedAppServiceTrialFeatures: assert detail_result["id"] == app_id assert detail_result["can_trial"] is has_trial_app - @patch("services.recommended_app_service.FeatureService", autospec=True) @patch("services.recommended_app_service.RecommendAppRetrievalFactory", autospec=True) @patch("services.recommended_app_service.dify_config") def test_get_detail_returns_none_before_reading_trial_flag( self, mock_config: MagicMock, mock_factory_class: MagicMock, - mock_feature_service: MagicMock, sqlite_session: Session, ) -> None: mock_config.HOSTED_FETCH_APP_TEMPLATES_MODE = "remote" mock_instance = MagicMock() mock_instance.get_recommend_app_detail.return_value = None mock_factory_class.get_recommend_app_factory.return_value = MagicMock(return_value=mock_instance) - - result = RecommendedAppService.get_recommend_app_detail("nonexistent", session=sqlite_session) + trial_policy = MagicMock(side_effect=AssertionError("missing app must not inspect trial policy")) + with patch.object(RecommendedAppService, "is_trial_app_enabled", trial_policy): + result = RecommendedAppService.get_recommend_app_detail("nonexistent", session=sqlite_session) assert result is None mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent", session=sqlite_session) - mock_feature_service.get_system_features.assert_not_called() + trial_policy.assert_not_called() def test_add_trial_app_record_increments_count_for_existing(self, sqlite_session: Session) -> None: app_id = str(uuid.uuid4()) diff --git a/packages/contracts/generated/api/console/explore/types.gen.ts b/packages/contracts/generated/api/console/explore/types.gen.ts index 331815aaf56..7528792fca1 100644 --- a/packages/contracts/generated/api/console/explore/types.gen.ts +++ b/packages/contracts/generated/api/console/explore/types.gen.ts @@ -20,7 +20,7 @@ export type BannerListResponse = Array export type RecommendedAppResponse = { app?: RecommendedAppInfoResponse | null app_id: string - can_trial?: boolean | null + can_trial: boolean categories?: Array copyright?: string | null custom_disclaimer?: string | null @@ -31,7 +31,7 @@ export type RecommendedAppResponse = { } export type RecommendedAppDetailResponse = { - can_trial?: boolean | null + can_trial: boolean export_data: string icon?: string | null icon_background?: string | null @@ -71,7 +71,7 @@ export type LearnDifyAppListResponseWritable = { export type RecommendedAppResponseWritable = { app?: RecommendedAppInfoResponseWritable | null app_id: string - can_trial?: boolean | null + can_trial: boolean categories?: Array copyright?: string | null custom_disclaimer?: string | null diff --git a/packages/contracts/generated/api/console/explore/zod.gen.ts b/packages/contracts/generated/api/console/explore/zod.gen.ts index b835f405436..895e7f9f230 100644 --- a/packages/contracts/generated/api/console/explore/zod.gen.ts +++ b/packages/contracts/generated/api/console/explore/zod.gen.ts @@ -6,7 +6,7 @@ import * as z from 'zod' * RecommendedAppDetailResponse */ export const zRecommendedAppDetailResponse = z.object({ - can_trial: z.boolean().nullish(), + can_trial: z.boolean(), export_data: z.string(), icon: z.string().nullish(), icon_background: z.string().nullish(), @@ -56,7 +56,7 @@ export const zRecommendedAppInfoResponse = z.object({ export const zRecommendedAppResponse = z.object({ app: zRecommendedAppInfoResponse.nullish(), app_id: z.string(), - can_trial: z.boolean().nullish(), + can_trial: z.boolean(), categories: z.array(z.string()).optional(), copyright: z.string().nullish(), custom_disclaimer: z.string().nullish(), @@ -99,7 +99,7 @@ export const zRecommendedAppInfoResponseWritable = z.object({ export const zRecommendedAppResponseWritable = z.object({ app: zRecommendedAppInfoResponseWritable.nullish(), app_id: z.string(), - can_trial: z.boolean().nullish(), + can_trial: z.boolean(), categories: z.array(z.string()).optional(), copyright: z.string().nullish(), custom_disclaimer: z.string().nullish(), diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index 076f98546cc..46ccea0793b 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -18,7 +18,6 @@ export type SystemFeatureModel = { enable_marketplace: boolean enable_social_oauth_login: boolean enable_step_by_step_tour: boolean - enable_trial_app: boolean is_allow_register: boolean is_email_setup: boolean knowledge_fs_enabled: boolean diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 20cf33d3891..7c2d20f47db 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -125,7 +125,6 @@ export const zSystemFeatureModel = z.object({ enable_marketplace: z.boolean().default(false), enable_social_oauth_login: z.boolean().default(false), enable_step_by_step_tour: z.boolean().default(false), - enable_trial_app: z.boolean().default(false), is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), knowledge_fs_enabled: z.boolean().default(false), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 14e9bbf2e53..d832ca3d98a 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -511,7 +511,6 @@ export type SystemFeatureModel = { enable_marketplace: boolean enable_social_oauth_login: boolean enable_step_by_step_tour: boolean - enable_trial_app: boolean is_allow_register: boolean is_email_setup: boolean knowledge_fs_enabled: boolean diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 3dbb9584950..0e1cd8fc76d 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -772,7 +772,6 @@ export const zSystemFeatureModel = z.object({ enable_marketplace: z.boolean().default(false), enable_social_oauth_login: z.boolean().default(false), enable_step_by_step_tour: z.boolean().default(false), - enable_trial_app: z.boolean().default(false), is_allow_register: z.boolean().default(false), is_email_setup: z.boolean().default(false), knowledge_fs_enabled: z.boolean().default(false), diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index c39835ba4c3..a3142b53882 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -266,6 +266,7 @@ describe('Apps', () => { icon_background: '#fff', mode: AppModeEnum.CHAT, export_data: 'yaml-content', + can_trial: true, }) }) @@ -305,6 +306,16 @@ describe('Apps', () => { expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument() }) + it('should close the template preview', async () => { + const user = userEvent.setup() + renderWithClient() + + await user.click(screen.getByTestId('open-preview')) + await user.click(await screen.findByTestId('try-app-close')) + + expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument() + }) + it('should open the create modal from Learn Dify', async () => { const user = userEvent.setup() renderWithClient() diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 07376c80d18..c593b97ca90 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -206,11 +206,11 @@ const Apps = () => { onCreateLearnDify={handleCreateLearnDify} onTryLearnDify={handleTryLearnDify} /> - {isShowTryAppPanel && ( + {isShowTryAppPanel && currentTryAppParams && ( diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 277afe0eb67..b8ec1a132ce 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -241,7 +241,6 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { const shouldCompleteHomeTourOnCreateRef = useRef(false) const isSubmittingHomeTourCreateRef = useRef(false) const wasHomeTryAppCreateGuideActiveRef = useRef(false) - const isShowTryAppPanel = !!currentTryApp const shouldForceShowLearnDifyForTour = activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID && !completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) && @@ -550,12 +549,12 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { /> )} - {isShowTryAppPanel && ( + {currentTryApp && ( - renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } }) +const defaultApp = { can_trial: true } as ExploreApp + +function TryApp({ + app = defaultApp, + ...props +}: Omit, 'app'> & { + app?: ExploreApp +}) { + return +} const mockUseGetTryAppInfo = vi.fn() @@ -143,6 +152,26 @@ describe('TryApp (main index.tsx)', () => { }) describe('content rendering', () => { + it('uses app trial eligibility as the authoritative default tab', async () => { + const app = { can_trial: true } as ExploreApp + + render() + + expect(await screen.findByTestId('app-component')).toBeInTheDocument() + }) + + it('defaults to details and disables trial when the app is ineligible', async () => { + const app = { can_trial: false } as ExploreApp + + render() + + expect(await screen.findByTestId('preview-component')).toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.try' })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) + it('renders Tab component', async () => { render() diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 32f3ba7a367..294dbbf17e0 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -1,17 +1,14 @@ /* eslint-disable style/multiline-ternary */ 'use client' -import type { FC } from 'react' import type { App as AppType } from '@/models/explore' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' -import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useGetTryAppInfo } from '@/service/use-try-app' import App from './app' import AppInfo from './app-info' @@ -20,7 +17,7 @@ import { TypeEnum } from './types' type Props = Readonly<{ appId: string - app?: AppType + app: AppType canCreate?: boolean categories?: string[] createButtonStepByStepTourTarget?: string @@ -28,7 +25,7 @@ type Props = Readonly<{ onCreate: () => void }> -const TryApp: FC = ({ +function TryApp({ appId, app, canCreate = true, @@ -36,11 +33,9 @@ const TryApp: FC = ({ createButtonStepByStepTourTarget, onClose, onCreate, -}) => { +}: Props) { const { t } = useTranslation() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app) - const canUseTryTab = systemFeatures.deployment_edition === 'CLOUD' && (app ? isTrialApp : true) + const canUseTryTab = app.can_trial const [type, setType] = useState(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL)) const activeType = canUseTryTab ? type : TypeEnum.DETAIL const { data: appDetail, isLoading, isError, error } = useGetTryAppInfo(appId) @@ -77,17 +72,15 @@ const TryApp: FC = ({ >
- {systemFeatures.deployment_edition === 'CLOUD' && ( - - - {t(($) => $['tryApp.tabHeader.try'], { ns: 'explore' })} - - - )} + + + {t(($) => $['tryApp.tabHeader.try'], { ns: 'explore' })} + + = ({
{/* Main content */}
- {systemFeatures.deployment_edition === 'CLOUD' && ( - - - - )} + + + diff --git a/web/service/explore.spec.ts b/web/service/explore.spec.ts index 4779caaa3c3..45200f46024 100644 --- a/web/service/explore.spec.ts +++ b/web/service/explore.spec.ts @@ -53,6 +53,7 @@ describe('explore service normalizers', () => { icon_background: '', mode: 'rag-pipeline', export_data: 'kind: app', + can_trial: false, }) await expect(fetchAppList()).resolves.toMatchObject({ diff --git a/web/service/explore.ts b/web/service/explore.ts index 8f474b09316..2e0a1081950 100644 --- a/web/service/explore.ts +++ b/web/service/explore.ts @@ -40,7 +40,7 @@ type ExploreAppDetailResponse = { icon_background: string mode: string export_data: string - can_trial?: boolean | null + can_trial: boolean } type InstalledAppsResponse = { @@ -141,7 +141,7 @@ const normalizeRecommendedApp = (app: RecommendedAppResponse): App => { installed: false, editable: false, is_agent: false, - can_trial: app.can_trial ?? false, + can_trial: app.can_trial, } } diff --git a/web/test/console/system-features.ts b/web/test/console/system-features.ts index 50f01491730..0d12aef5cab 100644 --- a/web/test/console/system-features.ts +++ b/web/test/console/system-features.ts @@ -50,7 +50,6 @@ const baseSystemFeatures = { }, rbac_enabled: false, enable_creators_platform: false, - enable_trial_app: false, enable_explore_banner: false, enable_learn_app: true, enable_step_by_step_tour: false, From 119b6532fd9544f173de0a24c715b4ee7b958c70 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:16:18 +0800 Subject: [PATCH 010/531] refactor(workflow): remove hook barrel exports (#39588) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- oxlint-suppressions.json | 20 - .../instruction-editor-in-workflow.tsx | 2 +- .../rag-pipeline/__tests__/index.spec.tsx | 2 +- .../__tests__/rag-pipeline-children.spec.tsx | 7 +- .../__tests__/rag-pipeline-main.spec.tsx | 35 +- .../input-field/__tests__/index.spec.tsx | 4 +- .../field-list/__tests__/hooks.spec.ts | 2 +- .../panel/input-field/field-list/hooks.ts | 2 +- .../components/panel/input-field/index.tsx | 4 +- .../__tests__/datasource.spec.tsx | 2 +- .../label-right-content/datasource.tsx | 2 +- .../preview/__tests__/form.spec.tsx | 2 +- .../preview/__tests__/index.spec.tsx | 4 +- .../panel/input-field/preview/form.tsx | 5 +- .../panel/input-field/preview/index.tsx | 2 +- .../panel/test-run/__tests__/header.spec.tsx | 2 +- .../components/panel/test-run/header.tsx | 2 +- .../preparation/__tests__/index.spec.tsx | 5 +- .../__tests__/option-card.spec.tsx | 2 +- .../data-source-options/option-card.tsx | 2 +- .../__tests__/index.spec.tsx | 2 +- .../preparation/document-processing/index.tsx | 5 +- .../panel/test-run/preparation/index.tsx | 2 +- .../components/rag-pipeline-children.tsx | 7 +- .../__tests__/run-mode.spec.tsx | 5 +- .../publisher/__tests__/index.spec.tsx | 5 +- .../publisher/__tests__/popup.spec.tsx | 2 +- .../rag-pipeline-header/publisher/index.tsx | 2 +- .../rag-pipeline-header/publisher/popup.tsx | 2 +- .../rag-pipeline-header/run-mode.tsx | 3 +- .../components/rag-pipeline-main.tsx | 16 +- .../hooks/__tests__/index.spec.ts | 489 ------------------ .../__tests__/use-inspect-vars-crud.spec.ts | 2 +- .../__tests__/use-nodes-sync-draft.spec.ts | 2 +- .../use-pipeline-refresh-draft.spec.ts | 2 +- .../hooks/__tests__/use-pipeline-run.spec.ts | 2 +- .../__tests__/use-pipeline-start-run.spec.ts | 7 +- .../hooks/__tests__/use-pipeline.spec.ts | 4 +- .../components/rag-pipeline/hooks/index.ts | 9 - .../hooks/use-inspect-vars-crud.ts | 2 +- .../hooks/use-nodes-sync-draft.ts | 2 +- .../hooks/use-pipeline-refresh-draft.ts | 2 +- .../rag-pipeline/hooks/use-pipeline-run.ts | 2 +- .../hooks/use-pipeline-start-run.tsx | 7 +- .../rag-pipeline/hooks/use-pipeline.tsx | 8 +- web/app/components/rag-pipeline/index.tsx | 2 +- .../__tests__/snippet-main.spec.tsx | 14 +- .../__tests__/snippet-run-panel.spec.tsx | 5 +- .../__tests__/run-mode.spec.tsx | 11 +- .../components/snippet-header/run-mode.tsx | 3 +- .../snippets/components/snippet-main.tsx | 2 +- .../snippets/components/snippet-run-panel.tsx | 3 +- .../__tests__/use-inspect-vars-crud.spec.ts | 2 +- .../use-snippet-refresh-draft.spec.ts | 2 +- .../hooks/__tests__/use-snippet-run.spec.ts | 2 +- .../__tests__/use-snippet-start-run.spec.ts | 2 +- .../snippets/hooks/use-inspect-vars-crud.ts | 2 +- .../hooks/use-snippet-refresh-draft.ts | 2 +- .../snippets/hooks/use-snippet-run.ts | 2 +- .../snippets/hooks/use-snippet-start-run.ts | 2 +- .../workflow-app/__tests__/index.spec.tsx | 4 +- .../__tests__/workflow-children.spec.tsx | 20 +- .../__tests__/workflow-main.spec.tsx | 41 +- .../__tests__/workflow-panel.spec.tsx | 2 +- .../components/workflow-children.tsx | 12 +- .../__tests__/chat-variable-trigger.spec.tsx | 4 +- .../__tests__/features-trigger.spec.tsx | 12 +- .../workflow-header/chat-variable-trigger.tsx | 4 +- .../workflow-header/features-trigger.tsx | 11 +- .../components/workflow-header/index.tsx | 2 +- .../workflow-app/components/workflow-main.tsx | 26 +- .../components/workflow-panel.tsx | 2 +- .../use-available-nodes-meta-data.spec.ts | 2 +- .../__tests__/use-nodes-sync-draft.spec.ts | 2 +- .../use-workflow-refresh-draft.spec.ts | 2 +- .../hooks/__tests__/use-workflow-run.spec.ts | 2 +- .../__tests__/use-workflow-start-run.spec.tsx | 10 +- .../__tests__/use-workflow-template.spec.ts | 2 +- .../components/workflow-app/hooks/index.ts | 13 - .../hooks/use-inspect-vars-crud.ts | 2 +- .../hooks/use-nodes-sync-draft.ts | 2 +- .../hooks/use-workflow-refresh-draft.ts | 2 +- .../workflow-app/hooks/use-workflow-run.ts | 4 +- .../hooks/use-workflow-start-run.tsx | 9 +- .../__tests__/candidate-node-main.spec.tsx | 17 +- .../workflow/__tests__/custom-edge.spec.tsx | 21 +- .../__tests__/edge-contextmenu.spec.tsx | 10 - .../workflow/__tests__/features.spec.tsx | 4 +- .../__tests__/panel-contextmenu.spec.tsx | 83 ++- .../__tests__/selection-contextmenu.spec.tsx | 11 +- .../__tests__/workflow-edge-events.spec.tsx | 71 ++- .../__tests__/all-start-blocks.spec.tsx | 4 +- .../__tests__/start-blocks.spec.tsx | 4 +- .../__tests__/use-insert-snippet.spec.tsx | 5 +- .../snippets/use-insert-snippet.ts | 3 +- .../workflow/candidate-node-main.tsx | 13 +- web/app/components/workflow/custom-edge.tsx | 3 +- .../components/workflow/edge-contextmenu.tsx | 2 +- web/app/components/workflow/features.tsx | 2 +- .../header/__tests__/env-button.spec.tsx | 2 +- .../__tests__/global-variable-button.spec.tsx | 2 +- .../__tests__/header-in-restoring.spec.tsx | 5 +- .../header/__tests__/header-layouts.spec.tsx | 16 +- .../workflow/header/__tests__/index.spec.tsx | 2 +- .../header/__tests__/run-and-history.spec.tsx | 5 +- .../header/__tests__/run-mode.spec.tsx | 51 +- .../header/__tests__/running-title.spec.tsx | 2 +- .../header/__tests__/undo-redo.spec.tsx | 15 +- .../header/__tests__/view-history.spec.tsx | 37 +- .../header/checklist/__tests__/index.spec.tsx | 5 +- .../workflow/header/checklist/index.tsx | 3 +- .../components/workflow/header/env-button.tsx | 2 +- .../header/global-variable-button.tsx | 2 +- .../workflow/header/header-in-normal.tsx | 6 +- .../workflow/header/header-in-restoring.tsx | 3 +- .../header/header-in-view-history.tsx | 2 +- web/app/components/workflow/header/index.tsx | 2 +- .../workflow/header/run-and-history.tsx | 3 +- .../components/workflow/header/run-mode.tsx | 8 +- .../workflow/header/running-title.tsx | 2 +- .../components/workflow/header/undo-redo.tsx | 2 +- .../workflow/header/view-history.tsx | 12 +- .../workflow/header/view-workflow-history.tsx | 3 +- .../use-nodes-available-var-list.spec.ts | 33 +- web/app/components/workflow/hooks/index.ts | 25 - .../workflow/hooks/use-checklist.ts | 3 +- .../hooks/use-fetch-workflow-inspect-vars.ts | 2 +- .../hooks/use-inspect-vars-crud-common.ts | 4 +- .../hooks/use-nodes-available-var-list.ts | 3 +- .../hooks/use-workflow-interactions.ts | 4 - .../__tests__/use-workflow-run-event.spec.ts | 38 +- .../hooks/use-workflow-run-event/index.ts | 19 - .../use-workflow-run-event.ts | 40 +- .../components/workflow/hooks/use-workflow.ts | 4 +- web/app/components/workflow/index.tsx | 20 +- .../__tests__/details.spec.tsx | 44 +- .../__tests__/index.spec.tsx | 31 +- .../change-block-menu-trigger.tsx | 8 +- .../use-node-actions-menu-model.ts | 8 +- .../nodes/_base/__tests__/node.spec.tsx | 25 +- .../form-input-item.branches.spec.tsx | 35 +- .../__tests__/form-input-item.spec.tsx | 35 +- .../__tests__/node-control.spec.tsx | 11 +- .../components/__tests__/node-handle.spec.tsx | 47 +- .../add-variable-popup-with-position.tsx | 4 +- .../_base/components/error-handle/hooks.ts | 3 +- .../next-step/__tests__/index.spec.tsx | 42 +- .../next-step/__tests__/operator.spec.tsx | 17 +- .../nodes/_base/components/next-step/add.tsx | 8 +- .../_base/components/next-step/index.tsx | 2 +- .../nodes/_base/components/next-step/item.tsx | 8 +- .../_base/components/next-step/operator.tsx | 3 +- .../nodes/_base/components/node-control.tsx | 3 +- .../nodes/_base/components/node-handle.tsx | 9 +- .../nodes/_base/components/node-resizer.tsx | 2 +- .../nodes/_base/components/prompt/editor.tsx | 2 +- .../readonly-input-with-select-var.tsx | 2 +- .../nodes/_base/components/retry/hooks.ts | 2 +- .../var-reference-picker.branches.spec.tsx | 38 +- .../__tests__/var-reference-picker.spec.tsx | 38 +- .../variable/var-reference-picker.tsx | 3 +- .../workflow-panel/__tests__/index.spec.tsx | 123 +++-- .../_base/components/workflow-panel/index.tsx | 19 +- .../last-run/__tests__/use-last-run.spec.ts | 20 +- .../workflow-panel/last-run/use-last-run.ts | 6 +- .../__tests__/use-available-var-list.spec.ts | 35 +- .../hooks/__tests__/use-node-crud.spec.ts | 15 +- .../hooks/__tests__/use-one-step-run.spec.ts | 71 +-- .../_base/hooks/use-available-var-list.ts | 3 +- .../nodes/_base/hooks/use-node-crud.ts | 2 +- .../nodes/_base/hooks/use-node-help-link.ts | 2 +- .../nodes/_base/hooks/use-one-step-run.ts | 5 +- .../nodes/_base/hooks/use-output-var-list.ts | 2 +- .../components/workflow/nodes/_base/node.tsx | 7 +- .../nodes/agent-v2/__tests__/panel.spec.tsx | 27 +- .../nodes/agent-v2/agent-soul-config.ts | 2 +- .../agent-v2/components/agent-task-field.tsx | 2 +- .../workflow/nodes/agent-v2/panel.tsx | 2 +- .../nodes/agent/__tests__/use-config.spec.ts | 13 +- .../workflow/nodes/agent/use-config.ts | 2 +- .../nodes/answer/__tests__/node.spec.tsx | 7 +- .../nodes/answer/__tests__/use-config.spec.ts | 11 +- .../workflow/nodes/answer/use-config.ts | 2 +- .../assigner/__tests__/use-config.spec.tsx | 37 +- .../var-list/__tests__/index.spec.tsx | 38 +- .../workflow/nodes/assigner/hooks.ts | 3 +- .../workflow/nodes/assigner/use-config.ts | 8 +- .../nodes/code/__tests__/use-config.spec.ts | 13 +- .../workflow/nodes/code/use-config.ts | 2 +- .../workflow/nodes/data-source-empty/hooks.ts | 4 +- .../nodes/data-source/__tests__/node.spec.tsx | 4 +- .../data-source/__tests__/panel.spec.tsx | 13 +- .../use-before-run-form.branches.spec.tsx | 24 +- .../__tests__/use-before-run-form.spec.tsx | 24 +- .../hooks/__tests__/use-config.spec.ts | 11 +- .../data-source/hooks/use-before-run-form.ts | 3 +- .../nodes/data-source/hooks/use-config.ts | 2 +- .../workflow/nodes/data-source/node.tsx | 2 +- .../workflow/nodes/data-source/panel.tsx | 2 +- .../__tests__/use-config.spec.ts | 33 +- .../nodes/document-extractor/use-config.ts | 8 +- .../nodes/end/__tests__/node.spec.tsx | 18 +- .../nodes/end/__tests__/use-config.spec.ts | 11 +- .../components/workflow/nodes/end/node.tsx | 3 +- .../workflow/nodes/end/use-config.ts | 2 +- .../nodes/http/__tests__/use-config.spec.ts | 13 +- .../components/__tests__/curl-panel.spec.tsx | 15 +- .../nodes/http/components/curl-panel.tsx | 2 +- .../workflow/nodes/http/use-config.ts | 2 +- .../__tests__/human-input.spec.tsx | 49 +- .../__tests__/form-content.spec.tsx | 11 +- .../delivery-method/__tests__/index.spec.tsx | 11 +- .../components/delivery-method/index.tsx | 2 +- .../human-input/components/form-content.tsx | 2 +- .../hooks/__tests__/use-config.spec.ts | 13 +- .../hooks/__tests__/use-form-content.spec.ts | 11 +- .../nodes/human-input/hooks/use-config.ts | 4 +- .../human-input/hooks/use-form-content.ts | 2 +- .../if-else/__tests__/use-config.spec.tsx | 27 +- .../condition-list/condition-item.tsx | 2 +- .../workflow/nodes/if-else/use-config.ts | 3 +- .../if-else/use-is-var-file-attribute.ts | 3 +- .../iteration-start/__tests__/index.spec.tsx | 30 +- .../iteration/__tests__/integration.spec.tsx | 8 +- .../iteration/__tests__/use-config.spec.ts | 17 +- .../__tests__/use-interactions.spec.tsx | 25 +- .../use-single-run-form-params.spec.ts | 13 +- .../workflow/nodes/iteration/add-block.tsx | 4 +- .../workflow/nodes/iteration/use-config.ts | 2 +- .../nodes/iteration/use-interactions.ts | 4 +- .../iteration/use-single-run-form-params.ts | 2 +- .../knowledge-base/__tests__/panel.spec.tsx | 11 +- .../hooks/__tests__/use-config.spec.tsx | 15 +- .../nodes/knowledge-base/hooks/use-config.ts | 2 +- .../workflow/nodes/knowledge-base/panel.tsx | 2 +- .../__tests__/use-config.spec.ts | 17 +- .../nodes/knowledge-retrieval/use-config.ts | 2 +- .../__tests__/use-config.spec.tsx | 33 +- .../nodes/list-operator/use-config.ts | 8 +- .../nodes/llm/__tests__/use-config.spec.ts | 19 +- .../use-single-run-form-params.spec.ts | 11 +- .../workflow/nodes/llm/use-config.ts | 4 +- .../nodes/llm/use-single-run-form-params.ts | 2 +- .../nodes/loop-start/__tests__/index.spec.tsx | 30 +- .../nodes/loop/__tests__/use-config.spec.tsx | 19 +- .../loop/__tests__/use-interactions.spec.tsx | 23 +- .../use-single-run-form-params.spec.ts | 2 +- .../workflow/nodes/loop/add-block.tsx | 4 +- .../workflow/nodes/loop/use-config.ts | 2 +- .../workflow/nodes/loop/use-interactions.ts | 4 +- .../nodes/loop/use-is-var-file-attribute.ts | 3 +- .../nodes/loop/use-single-run-form-params.ts | 2 +- .../nodes/parameter-extractor/use-config.ts | 2 +- .../__tests__/integration.spec.tsx | 6 +- .../__tests__/use-config.spec.ts | 21 +- .../components/__tests__/class-list.spec.tsx | 6 +- .../components/class-list.tsx | 2 +- .../nodes/question-classifier/use-config.ts | 2 +- .../__tests__/panel.spec.tsx | 14 +- .../nodes/start-placeholder/panel.tsx | 4 +- .../nodes/start/__tests__/use-config.spec.ts | 17 +- .../use-single-run-form-params.spec.ts | 11 +- .../workflow/nodes/start/use-config.ts | 2 +- .../nodes/start/use-single-run-form-params.ts | 2 +- .../__tests__/use-config.spec.ts | 13 +- .../nodes/template-transform/use-config.ts | 2 +- .../nodes/tool/__tests__/node.spec.tsx | 2 +- .../tool/hooks/__tests__/use-config.spec.tsx | 11 +- .../use-single-run-form-params.spec.ts | 11 +- .../workflow/nodes/tool/hooks/use-config.ts | 2 +- .../tool/hooks/use-single-run-form-params.ts | 2 +- .../components/workflow/nodes/tool/node.tsx | 2 +- .../workflow/nodes/trigger-plugin/node.tsx | 2 +- .../nodes/trigger-plugin/use-config.ts | 2 +- .../__tests__/use-config.spec.ts | 13 +- .../nodes/trigger-schedule/use-config.ts | 2 +- .../__tests__/use-config.spec.tsx | 19 +- .../nodes/trigger-webhook/use-config.ts | 2 +- .../variable-assigner/__tests__/hooks.spec.ts | 33 +- .../__tests__/use-config.spec.tsx | 23 +- .../workflow/nodes/variable-assigner/hooks.ts | 4 +- .../nodes/variable-assigner/use-config.ts | 2 +- .../note-node/__tests__/hooks.spec.tsx | 5 +- .../note-node/__tests__/index.spec.tsx | 11 +- .../components/workflow/note-node/hooks.ts | 3 +- .../components/workflow/note-node/index.tsx | 3 +- .../operator/__tests__/add-block.spec.tsx | 17 +- .../operator/__tests__/control.spec.tsx | 8 +- .../operator/__tests__/index.spec.tsx | 11 +- .../operator/__tests__/more-actions.spec.tsx | 15 +- .../operator/__tests__/zoom-in-out.spec.tsx | 31 +- .../workflow/operator/add-block.tsx | 11 +- .../components/workflow/operator/control.tsx | 4 +- .../workflow/operator/more-actions.tsx | 2 +- .../workflow/operator/zoom-in-out.tsx | 3 +- .../components/workflow/panel-contextmenu.tsx | 12 +- .../workflow/panel/__tests__/record.spec.tsx | 15 +- .../panel/__tests__/workflow-preview.spec.tsx | 16 +- .../workflow/panel/chat-record/index.tsx | 2 +- .../panel/chat-variable-panel/index.tsx | 2 +- .../comments-panel/__tests__/index.spec.tsx | 2 +- .../workflow/panel/comments-panel/index.tsx | 2 +- .../debug-and-preview/__tests__/hooks.spec.ts | 5 +- .../__tests__/hooks/handle-resume.spec.ts | 5 +- .../__tests__/hooks/handle-send.spec.ts | 5 +- .../hooks/handle-stop-restart.spec.ts | 5 +- .../__tests__/hooks/misc.spec.ts | 5 +- .../__tests__/hooks/opening-statement.spec.ts | 5 +- .../__tests__/hooks/sse-callbacks.spec.ts | 5 +- .../workflow/panel/debug-and-preview/hooks.ts | 3 +- .../panel/debug-and-preview/index.tsx | 6 +- .../panel/env-panel/__tests__/index.spec.tsx | 2 +- .../workflow/panel/env-panel/index.tsx | 4 +- .../__tests__/index.spec.tsx | 2 +- .../panel/global-variable-panel/index.tsx | 2 +- .../workflow/panel/inputs-panel.tsx | 2 +- web/app/components/workflow/panel/record.tsx | 2 +- .../__tests__/index.spec.tsx | 8 +- .../panel/version-history-panel/index.tsx | 4 +- .../workflow/panel/workflow-preview.tsx | 2 +- .../workflow/selection-contextmenu.tsx | 6 +- .../simple-node/__tests__/index.spec.tsx | 15 +- .../components/workflow/simple-node/index.tsx | 2 +- .../workflow/store/workflow/node-slice.ts | 2 +- .../variable-inspect/__tests__/group.spec.tsx | 4 +- .../__tests__/listening.spec.tsx | 2 +- .../variable-inspect/__tests__/panel.spec.tsx | 6 +- .../workflow/variable-inspect/group.tsx | 2 +- .../workflow/variable-inspect/listening.tsx | 2 +- .../workflow/variable-inspect/right.tsx | 3 +- 330 files changed, 2048 insertions(+), 1631 deletions(-) delete mode 100644 web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts delete mode 100644 web/app/components/rag-pipeline/hooks/index.ts delete mode 100644 web/app/components/workflow-app/hooks/index.ts delete mode 100644 web/app/components/workflow/hooks/index.ts delete mode 100644 web/app/components/workflow/hooks/use-workflow-interactions.ts delete mode 100644 web/app/components/workflow/hooks/use-workflow-run-event/index.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 0569de71067..f98f7a16fa9 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3634,11 +3634,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/hooks/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 9 - } - }, "web/app/components/rag-pipeline/hooks/use-DSL.ts": { "typescript/no-explicit-any": { "count": 1 @@ -3891,11 +3886,6 @@ "count": 3 } }, - "web/app/components/workflow-app/hooks/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 13 - } - }, "web/app/components/workflow-app/hooks/use-DSL.ts": { "typescript/no-explicit-any": { "count": 1 @@ -4035,11 +4025,6 @@ "count": 1 } }, - "web/app/components/workflow/hooks/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 25 - } - }, "web/app/components/workflow/hooks/use-checklist.ts": { "typescript/no-empty-object-type": { "count": 1 @@ -4068,11 +4053,6 @@ "count": 1 } }, - "web/app/components/workflow/hooks/use-workflow-run-event/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 19 - } - }, "web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-agent-log.ts": { "typescript/no-explicit-any": { "count": 1 diff --git a/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx b/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx index d6e0724ee2e..b3d27f30e2a 100644 --- a/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx +++ b/web/app/components/app/configuration/config/automatic/instruction-editor-in-workflow.tsx @@ -4,7 +4,7 @@ import type { GeneratorType } from './types' import type { ValueSelector, Var } from '@/app/components/workflow/types' import * as React from 'react' import { useCallback } from 'react' -import { useWorkflowVariableType } from '@/app/components/workflow/hooks' +import { useWorkflowVariableType } from '@/app/components/workflow/hooks/use-workflow-variables' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import { useWorkflowStore } from '@/app/components/workflow/store' import { VarType } from '@/app/components/workflow/types' diff --git a/web/app/components/rag-pipeline/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/__tests__/index.spec.tsx index 47219cca7dd..aee7f2c13a2 100644 --- a/web/app/components/rag-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/__tests__/index.spec.tsx @@ -9,7 +9,7 @@ vi.mock('@/context/dataset-detail', () => ({ selector({ dataset: pipelineId ? { pipeline_id: pipelineId } : undefined }), })) -vi.mock('../hooks', () => ({ +vi.mock('../hooks/use-pipeline-init', () => ({ usePipelineInit: () => pipelineInit, })) diff --git a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx index 6614faa0d17..4eb05f9f445 100644 --- a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-children.spec.tsx @@ -60,11 +60,14 @@ vi.mock('@/app/components/workflow/hooks-store', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-DSL', () => ({ useDSL: () => ({ exportCheck: mockExportCheck, handleExportDSL: mockHandleExportDSL, }), +})) + +vi.mock('@/app/components/workflow/hooks/use-panel-interactions', () => ({ usePanelInteractions: () => ({ handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, }), @@ -74,7 +77,7 @@ vi.mock('../../hooks/use-rag-pipeline-search', () => ({ useRagPipelineSearch: mockUseRagPipelineSearch, })) -vi.mock('../../../workflow/plugin-dependency', () => ({ +vi.mock('@/app/components/workflow/plugin-dependency', () => ({ default: () =>
, })) diff --git a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx index e384f581b1f..d089b483292 100644 --- a/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/rag-pipeline-main.spec.tsx @@ -17,19 +17,24 @@ vi.mock('@/context/workspace-state', async () => { })) }) -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-available-nodes-meta-data', () => ({ useAvailableNodesMetaData: () => ({ nodes: [], nodesMap: {} }), - useDSL: () => ({ - exportCheck: vi.fn(), - handleExportDSL: vi.fn(), - }), +})) + +vi.mock('../../hooks/use-DSL', () => ({ useDSLByCanEdit: () => ({ exportCheck: vi.fn(), handleExportDSL: vi.fn(), }), +})) + +vi.mock('../../hooks/use-get-run-and-trace-url', () => ({ useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: vi.fn(), }), +})) + +vi.mock('../../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ doSyncWorkflowDraft: vi.fn(), syncWorkflowDraftWhenPageClose: vi.fn(), @@ -38,16 +43,15 @@ vi.mock('../../hooks', () => ({ doSyncWorkflowDraft: vi.fn(), syncWorkflowDraftWhenPageClose: vi.fn(), }), +})) + +vi.mock('../../hooks/use-pipeline-refresh-draft', () => ({ usePipelineRefreshDraft: () => ({ handleRefreshWorkflowDraft: vi.fn(), }), - usePipelineRun: () => ({ - handleBackupDraft: vi.fn(), - handleLoadBackupDraft: vi.fn(), - handleRestoreFromPublishedWorkflow: vi.fn(), - handleRun: vi.fn(), - handleStopRun: vi.fn(), - }), +})) + +vi.mock('../../hooks/use-pipeline-run', () => ({ usePipelineRunByCanEdit: () => ({ handleBackupDraft: vi.fn(), handleLoadBackupDraft: vi.fn(), @@ -55,10 +59,9 @@ vi.mock('../../hooks', () => ({ handleRun: vi.fn(), handleStopRun: vi.fn(), }), - usePipelineStartRun: () => ({ - handleStartWorkflowRun: vi.fn(), - handleWorkflowStartRunInWorkflow: vi.fn(), - }), +})) + +vi.mock('../../hooks/use-pipeline-start-run', () => ({ usePipelineStartRunByCanEdit: () => ({ handleStartWorkflowRun: vi.fn(), handleWorkflowStartRunInWorkflow: vi.fn(), diff --git a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx index 84cc234ebb6..59918decc06 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/__tests__/index.spec.tsx @@ -18,7 +18,7 @@ let mockIsPreviewing = false let mockIsEditing = false let mockCanEdit = true -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('../../../../hooks/use-input-field-panel', () => ({ useInputFieldPanel: () => ({ closeAllInputFieldPanels: mockCloseAllInputFieldPanels, toggleInputFieldPreviewPanel: mockToggleInputFieldPreviewPanel, @@ -61,7 +61,7 @@ vi.mock('@/app/components/workflow/store', () => ({ const mockHandleSyncWorkflowDraft = vi.fn() -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, }), diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts index 3876c2d535d..25f62b86f38 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/__tests__/hooks.spec.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useFieldList } from '../hooks' const mockToggleInputFieldEditPanel = vi.fn() -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('../../../../../hooks/use-input-field-panel', () => ({ useInputFieldPanel: () => ({ toggleInputFieldEditPanel: mockToggleInputFieldEditPanel, }), diff --git a/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts b/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts index a85c90f7025..e7a99b0e46c 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/field-list/hooks.ts @@ -6,8 +6,8 @@ import { useBoolean } from 'ahooks' import { produce } from 'immer' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' import { ChangeType } from '@/app/components/workflow/types' +import { useInputFieldPanel } from '../../../../hooks/use-input-field-panel' import { usePipeline } from '../../../../hooks/use-pipeline' const VARIABLE_PREFIX = 'rag' diff --git a/web/app/components/rag-pipeline/components/panel/input-field/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/index.tsx index a59bae24638..a1544fbb702 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/index.tsx @@ -9,11 +9,11 @@ import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import Divider from '@/app/components/base/divider' import { Infotip } from '@/app/components/base/infotip' -import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' +import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' +import { useInputFieldPanel } from '../../../hooks/use-input-field-panel' import FieldList from './field-list' import FooterTip from './footer-tip' import Datasource from './label-right-content/datasource' diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx index b0ab5d53125..5a15d0e035f 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/__tests__/datasource.spec.tsx @@ -2,7 +2,7 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so import { render, screen } from '@testing-library/react' import Datasource from '../datasource' -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({ useToolIcon: () => 'tool-icon', })) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx index 4c16a1b1af8..1455efc2451 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/label-right-content/datasource.tsx @@ -1,7 +1,7 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import * as React from 'react' import BlockIcon from '@/app/components/workflow/block-icon' -import { useToolIcon } from '@/app/components/workflow/hooks' +import { useToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' import { BlockEnum } from '@/app/components/workflow/types' type DatasourceProps = { diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx index e721b44984b..292e6572173 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/form.spec.tsx @@ -27,7 +27,7 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => ({ default: mockBaseField, })) -vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ +vi.mock('../../../../../hooks/use-input-fields', () => ({ useInitialData: mockUseInitialData, useConfigurations: mockUseConfigurations, })) diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx index c27ddd72c7a..c34c535c3f9 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ vi.mock('../../hooks', () => ({ })) const mockToggleInputFieldPreviewPanel = vi.fn() -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('../../../../../hooks/use-input-field-panel', () => ({ useInputFieldPanel: () => ({ toggleInputFieldPreviewPanel: mockToggleInputFieldPreviewPanel, isPreviewing: true, @@ -108,7 +108,7 @@ const mapOptionToObject = (option: string) => ({ value: option, }) -vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ +vi.mock('../../../../../hooks/use-input-fields', () => ({ useInitialData: (variables: RAGPipelineVariables) => { return React.useMemo(() => { return variables.reduce( diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx index fda2715f2eb..388bd091e21 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/form.tsx @@ -1,10 +1,7 @@ import type { RAGPipelineVariables } from '@/models/pipeline' import { useAppForm } from '@/app/components/base/form' import BaseField from '@/app/components/base/form/form-scenarios/base/field' -import { - useConfigurations, - useInitialData, -} from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { useConfigurations, useInitialData } from '../../../../hooks/use-input-fields' type FormProps = { variables: RAGPipelineVariables diff --git a/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx b/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx index 1b64009f8fb..e00d022d401 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/input-field/preview/index.tsx @@ -5,7 +5,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import Divider from '@/app/components/base/divider' -import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' +import { useInputFieldPanel } from '../../../../hooks/use-input-field-panel' import { useFloatingRight } from '../hooks' import DataSource from './data-source' import ProcessDocuments from './process-documents' diff --git a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx index c420646e8d1..e4e4e69f8c9 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/__tests__/header.spec.tsx @@ -17,7 +17,7 @@ vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => mockWorkflowStore, })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({ useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, }), diff --git a/web/app/components/rag-pipeline/components/panel/test-run/header.tsx b/web/app/components/rag-pipeline/components/panel/test-run/header.tsx index a9714da31b6..6521e91495a 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/header.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/header.tsx @@ -2,7 +2,7 @@ import { RiCloseLine } from '@remixicon/react' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useWorkflowInteractions } from '@/app/components/workflow/hooks' +import { useWorkflowInteractions } from '@/app/components/workflow/hooks/use-workflow-panel-interactions' import { useWorkflowStore } from '@/app/components/workflow/store' const Header = () => { diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx index dd3f6468be3..0e55689be05 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/__tests__/index.spec.tsx @@ -102,10 +102,13 @@ vi.mock('@/app/components/workflow/store', () => ({ const mockHandleRun = vi.fn() -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun, }), +})) + +vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({ useToolIcon: () => ({ type: 'icon', icon: 'test-icon' }), })) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx index 81edc1ab73c..4c10ee9fe08 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/__tests__/option-card.spec.tsx @@ -2,7 +2,7 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so import { fireEvent, render, screen } from '@testing-library/react' import OptionCard from '../option-card' -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({ useToolIcon: () => 'source-icon', })) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx index a15f5d05b3b..48aecf54a94 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx @@ -3,7 +3,7 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useCallback } from 'react' import BlockIcon from '@/app/components/workflow/block-icon' -import { useToolIcon } from '@/app/components/workflow/hooks' +import { useToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' import { BlockEnum } from '@/app/components/workflow/types' type OptionCardProps = { diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx index 066e830fe78..6ab4683fb43 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/__tests__/index.spec.tsx @@ -48,7 +48,7 @@ vi.mock('@/service/use-pipeline', () => ({ const mockUseInitialData = vi.fn() const mockUseConfigurations = vi.fn() -vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ +vi.mock('../../../../../../hooks/use-input-fields', () => ({ useInitialData: (variables: RAGPipelineVariables) => mockUseInitialData(variables), useConfigurations: (variables: RAGPipelineVariables) => mockUseConfigurations(variables), })) diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx index a22d1d7edf9..677847204cb 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx @@ -2,10 +2,7 @@ import type { CustomActionsProps } from '@/app/components/base/form/components/f import * as React from 'react' import { useCallback } from 'react' import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils' -import { - useConfigurations, - useInitialData, -} from '@/app/components/rag-pipeline/hooks/use-input-fields' +import { useConfigurations, useInitialData } from '../../../../../hooks/use-input-fields' import Actions from './actions' import { useInputVariables } from './hooks' import Options from './options' diff --git a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx b/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx index 533af1fe847..bbd65538715 100644 --- a/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/test-run/preparation/index.tsx @@ -11,7 +11,7 @@ import { useDataSourceStoreWithSelector, } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' import WebsiteCrawl from '@/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl' -import { useWorkflowRun } from '@/app/components/workflow/hooks' +import { useWorkflowRun } from '@/app/components/workflow/hooks/use-workflow-run' import { useWorkflowStore } from '@/app/components/workflow/store' import { DatasourceType } from '@/models/pipeline' import { TransferMethod } from '@/types/app' diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx index 066f035ff88..38dd6103808 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-children.tsx @@ -2,11 +2,12 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { memo, useState } from 'react' import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants' import DSLExportConfirmModal from '@/app/components/workflow/dsl-export-confirm-modal' -import { useDSL, usePanelInteractions } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' +import { useDSL } from '@/app/components/workflow/hooks/use-DSL' +import { usePanelInteractions } from '@/app/components/workflow/hooks/use-panel-interactions' +import PluginDependency from '@/app/components/workflow/plugin-dependency' +import { useStore } from '@/app/components/workflow/store' import { useEventEmitterContextContext } from '@/context/event-emitter' -import PluginDependency from '../../workflow/plugin-dependency' -import { useStore } from '../../workflow/store' import { useRagPipelineSearch } from '../hooks/use-rag-pipeline-search' import RagPipelinePanel from './panel' import PublishToast from './publish-toast' diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx index 205fa856edd..aeadb735879 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/run-mode.spec.tsx @@ -33,10 +33,13 @@ vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { let mockWorkflowRunningData: { task_id: string; result: { status: string } } | undefined let mockIsPreparingDataSource = false -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleStopRun: mockHandleStopRun, }), +})) + +vi.mock('@/app/components/workflow/hooks/use-workflow-start-run', () => ({ useWorkflowStartRun: () => ({ handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow, }), diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index 521b3bf1533..1f424db1a38 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -100,10 +100,13 @@ vi.mock('@/next/link', () => ({ const mockHandleSyncWorkflowDraft = vi.fn() const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, }), +})) + +vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({ useChecklistBeforePublish: () => ({ handleCheckBeforePublish: mockHandleCheckBeforePublish, }), diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index 946230c46ce..afa24f487aa 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -173,7 +173,7 @@ vi.mock('@/config', async (importOriginal) => ({ MARKETPLACE_API_PREFIX: '/marketplace/api', })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({ useChecklistBeforePublish: () => ({ handleCheckBeforePublish: mockHandleCheckBeforePublish, }), diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx index d6222a20f1e..5757cf6fdbd 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx @@ -7,8 +7,8 @@ import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' +import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { useStore } from '@/app/components/workflow/store' import { useDocLink } from '@/context/i18n' import Link from '@/next/link' diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 32039faf5e9..b414e3eb394 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -23,7 +23,7 @@ import { trackEvent } from '@/app/components/base/amplitude' import Divider from '@/app/components/base/divider' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' import PremiumBadge from '@/app/components/base/premium-badge' -import { useChecklistBeforePublish } from '@/app/components/workflow/hooks' +import { useChecklistBeforePublish } from '@/app/components/workflow/hooks/use-checklist' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { userProfileIdAtom } from '@/context/account-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx index 26ec0765d9d..273b7cd8567 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx @@ -5,8 +5,9 @@ import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' -import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' +import { useWorkflowRun } from '@/app/components/workflow/hooks/use-workflow-run' +import { useWorkflowStartRun } from '@/app/components/workflow/hooks/use-workflow-start-run' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx index 6700c4ea744..c6a2b53fd88 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx @@ -9,17 +9,15 @@ import { userProfileIdAtom } from '@/context/account-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { getDatasetACLCapabilities } from '@/utils/permission' -import { - useAvailableNodesMetaData, - useDSLByCanEdit, - useGetRunAndTraceUrl, - useNodesSyncDraftByCanEdit, - usePipelineRefreshDraft, - usePipelineRunByCanEdit, - usePipelineStartRunByCanEdit, -} from '../hooks' +import { useAvailableNodesMetaData } from '../hooks/use-available-nodes-meta-data' import { useConfigsMap } from '../hooks/use-configs-map' +import { useDSLByCanEdit } from '../hooks/use-DSL' +import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url' import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud' +import { useNodesSyncDraftByCanEdit } from '../hooks/use-nodes-sync-draft' +import { usePipelineRefreshDraft } from '../hooks/use-pipeline-refresh-draft' +import { usePipelineRunByCanEdit } from '../hooks/use-pipeline-run' +import { usePipelineStartRunByCanEdit } from '../hooks/use-pipeline-start-run' import RagPipelineChildren from './rag-pipeline-children' type RagPipelineMainProps = Pick diff --git a/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts deleted file mode 100644 index 1828399211c..00000000000 --- a/web/app/components/rag-pipeline/hooks/__tests__/index.spec.ts +++ /dev/null @@ -1,489 +0,0 @@ -import type { RAGPipelineVariables, VAR_TYPE_MAP } from '@/models/pipeline' -import { act } from 'react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { BlockEnum } from '@/app/components/workflow/types' -import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' -import { Resolution, TransferMethod } from '@/types/app' -import { FlowType } from '@/types/common' -import { - useAvailableNodesMetaData, - useGetRunAndTraceUrl, - useInputFieldPanel, - useNodesSyncDraft, - usePipelineInit, - usePipelineRefreshDraft, -} from '../index' -import { useConfigsMap } from '../use-configs-map' -import { useConfigurations, useInitialData } from '../use-input-fields' -import { usePipelineTemplate } from '../use-pipeline-template' - -const _mockGetState = vi.fn() -const mockUseStore = vi.fn() -const mockUseWorkflowStore = vi.fn() -const toastMocks = vi.hoisted(() => ({ - error: vi.fn(), -})) - -vi.mock('@/app/components/workflow/store', () => ({ - useStore: (selector: (state: Record) => unknown) => mockUseStore(selector), - useWorkflowStore: () => mockUseWorkflowStore(), -})) - -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: { - error: toastMocks.error, - }, -})) - -const mockEventEmit = vi.fn() -vi.mock('@/context/event-emitter', () => ({ - useEventEmitterContextContext: () => ({ - eventEmitter: { - emit: mockEventEmit, - }, - }), -})) - -vi.mock('@/app/components/workflow/constants', () => ({ - DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK', - WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', - START_INITIAL_POSITION: { x: 100, y: 100 }, -})) - -vi.mock('@/app/components/workflow/constants/node', () => ({ - WORKFLOW_COMMON_NODES: [ - { - metaData: { type: BlockEnum.Start }, - defaultValue: { type: BlockEnum.Start }, - }, - { - metaData: { type: BlockEnum.End }, - defaultValue: { type: BlockEnum.End }, - }, - ], -})) - -vi.mock('@/app/components/workflow/nodes/data-source-empty/default', () => ({ - default: { - metaData: { type: BlockEnum.DataSourceEmpty }, - defaultValue: { type: BlockEnum.DataSourceEmpty }, - }, -})) - -vi.mock('@/app/components/workflow/nodes/data-source/default', () => ({ - default: { - metaData: { type: BlockEnum.DataSource }, - defaultValue: { type: BlockEnum.DataSource }, - }, -})) - -vi.mock('@/app/components/workflow/nodes/knowledge-base/default', () => ({ - default: { - metaData: { type: BlockEnum.KnowledgeBase }, - defaultValue: { type: BlockEnum.KnowledgeBase }, - }, -})) - -vi.mock('@/app/components/workflow/utils', async (importOriginal) => { - const actual = (await importOriginal()) as Record - return { - ...actual, - generateNewNode: ({ - id, - data, - position, - }: { - id: string - data: object - position: { x: number; y: number } - }) => ({ - newNode: { id, data, position, type: 'custom' }, - }), - } -}) - -const mockExportPipelineConfig = vi.fn() -vi.mock('@/service/use-pipeline', () => ({ - useExportPipelineDSL: () => ({ - mutateAsync: mockExportPipelineConfig, - }), -})) - -vi.mock('@/service/workflow', () => ({ - fetchWorkflowDraft: vi.fn().mockResolvedValue({ - graph: { nodes: [], edges: [], viewport: {} }, - environment_variables: [], - }), -})) - -describe('useConfigsMap', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseStore.mockImplementation((selector: (state: Record) => unknown) => { - const state = { - pipelineId: 'test-pipeline-id', - fileUploadConfig: { max_file_size: 10 }, - } - return selector(state) - }) - }) - - it('should return config map with correct flowId', () => { - const { result } = renderHook(() => useConfigsMap()) - - expect(result.current.flowId).toBe('test-pipeline-id') - }) - - it('should return config map with correct flowType', () => { - const { result } = renderHook(() => useConfigsMap()) - - expect(result.current.flowType).toBe(FlowType.ragPipeline) - }) - - it('should return file settings with image config', () => { - const { result } = renderHook(() => useConfigsMap()) - - expect(result.current.fileSettings.image).toEqual({ - enabled: false, - detail: Resolution.high, - number_limits: 3, - transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url], - }) - }) - - it('should include fileUploadConfig from store', () => { - const { result } = renderHook(() => useConfigsMap()) - - expect(result.current.fileSettings.fileUploadConfig).toEqual({ max_file_size: 10 }) - }) -}) - -describe('useGetRunAndTraceUrl', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseWorkflowStore.mockReturnValue({ - getState: () => ({ - pipelineId: 'pipeline-123', - }), - }) - }) - - it('should return getWorkflowRunAndTraceUrl function', () => { - const { result } = renderHook(() => useGetRunAndTraceUrl()) - - expect(result.current.getWorkflowRunAndTraceUrl).toBeDefined() - expect(typeof result.current.getWorkflowRunAndTraceUrl).toBe('function') - }) - - it('should generate correct run URL', () => { - const { result } = renderHook(() => useGetRunAndTraceUrl()) - - const { runUrl } = result.current.getWorkflowRunAndTraceUrl('run-456') - - expect(runUrl).toBe('/rag/pipelines/pipeline-123/workflow-runs/run-456') - }) - - it('should generate correct trace URL', () => { - const { result } = renderHook(() => useGetRunAndTraceUrl()) - - const { traceUrl } = result.current.getWorkflowRunAndTraceUrl('run-456') - - expect(traceUrl).toBe('/rag/pipelines/pipeline-123/workflow-runs/run-456/node-executions') - }) -}) - -describe('useInputFieldPanel', () => { - const mockSetShowInputFieldPanel = vi.fn() - const mockSetShowInputFieldPreviewPanel = vi.fn() - const mockSetInputFieldEditPanelProps = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - mockUseStore.mockImplementation((selector: (state: Record) => unknown) => { - const state = { - showInputFieldPreviewPanel: false, - inputFieldEditPanelProps: null, - } - return selector(state) - }) - mockUseWorkflowStore.mockReturnValue({ - getState: () => ({ - showInputFieldPreviewPanel: false, - setShowInputFieldPanel: mockSetShowInputFieldPanel, - setShowInputFieldPreviewPanel: mockSetShowInputFieldPreviewPanel, - setInputFieldEditPanelProps: mockSetInputFieldEditPanelProps, - }), - }) - }) - - it('should return isPreviewing as false when showInputFieldPreviewPanel is false', () => { - const { result } = renderHook(() => useInputFieldPanel()) - - expect(result.current.isPreviewing).toBe(false) - }) - - it('should return isPreviewing as true when showInputFieldPreviewPanel is true', () => { - mockUseStore.mockImplementation((selector: (state: Record) => unknown) => { - const state = { - showInputFieldPreviewPanel: true, - inputFieldEditPanelProps: null, - } - return selector(state) - }) - - const { result } = renderHook(() => useInputFieldPanel()) - - expect(result.current.isPreviewing).toBe(true) - }) - - it('should return isEditing as false when inputFieldEditPanelProps is null', () => { - const { result } = renderHook(() => useInputFieldPanel()) - - expect(result.current.isEditing).toBe(false) - }) - - it('should return isEditing as true when inputFieldEditPanelProps exists', () => { - mockUseStore.mockImplementation((selector: (state: Record) => unknown) => { - const state = { - showInputFieldPreviewPanel: false, - inputFieldEditPanelProps: { some: 'props' }, - } - return selector(state) - }) - - const { result } = renderHook(() => useInputFieldPanel()) - - expect(result.current.isEditing).toBe(true) - }) - - it('should call all setters when closeAllInputFieldPanels is called', () => { - const { result } = renderHook(() => useInputFieldPanel()) - - act(() => { - result.current.closeAllInputFieldPanels() - }) - - expect(mockSetShowInputFieldPanel).toHaveBeenCalledWith(false) - expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(false) - expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(null) - }) - - it('should toggle preview panel when toggleInputFieldPreviewPanel is called', () => { - const { result } = renderHook(() => useInputFieldPanel()) - - act(() => { - result.current.toggleInputFieldPreviewPanel() - }) - - expect(mockSetShowInputFieldPreviewPanel).toHaveBeenCalledWith(true) - }) - - it('should set edit panel props when toggleInputFieldEditPanel is called', () => { - const { result } = renderHook(() => useInputFieldPanel()) - const editContent = { onClose: vi.fn(), onSubmit: vi.fn() } - - act(() => { - result.current.toggleInputFieldEditPanel(editContent) - }) - - expect(mockSetInputFieldEditPanelProps).toHaveBeenCalledWith(editContent) - }) -}) - -describe('useInitialData', () => { - it('should return empty object for empty variables', () => { - const { result } = renderHook(() => useInitialData([], undefined)) - - expect(result.current).toEqual({}) - }) - - it('should handle text input type with default value', () => { - const variables: RAGPipelineVariables = [ - { - type: 'text-input' as keyof typeof VAR_TYPE_MAP, - variable: 'textVar', - label: 'Text', - required: false, - default_value: 'default text', - belong_to_node_id: 'node-1', - }, - ] - - const { result } = renderHook(() => useInitialData(variables, undefined)) - - expect(result.current.textVar).toBe('default text') - }) - - it('should use lastRunInputData over default value', () => { - const variables: RAGPipelineVariables = [ - { - type: 'text-input' as keyof typeof VAR_TYPE_MAP, - variable: 'textVar', - label: 'Text', - required: false, - default_value: 'default text', - belong_to_node_id: 'node-1', - }, - ] - - const { result } = renderHook(() => useInitialData(variables, { textVar: 'last run value' })) - - expect(result.current.textVar).toBe('last run value') - }) - - it('should handle number input type with default 0', () => { - const variables: RAGPipelineVariables = [ - { - type: 'number' as keyof typeof VAR_TYPE_MAP, - variable: 'numVar', - label: 'Number', - required: false, - belong_to_node_id: 'node-1', - }, - ] - - const { result } = renderHook(() => useInitialData(variables, undefined)) - - expect(result.current.numVar).toBe(0) - }) - - it('should handle file type with default empty array', () => { - const variables: RAGPipelineVariables = [ - { - type: 'file' as keyof typeof VAR_TYPE_MAP, - variable: 'fileVar', - label: 'File', - required: false, - belong_to_node_id: 'node-1', - }, - ] - - const { result } = renderHook(() => useInitialData(variables, undefined)) - - expect(result.current.fileVar).toEqual([]) - }) -}) - -describe('useConfigurations', () => { - it('should return empty array for empty variables', () => { - const { result } = renderHook(() => useConfigurations([])) - - expect(result.current).toEqual([]) - }) - - it('should transform variables to configurations', () => { - const variables: RAGPipelineVariables = [ - { - type: 'text-input' as keyof typeof VAR_TYPE_MAP, - variable: 'textVar', - label: 'Text Label', - required: true, - max_length: 100, - placeholder: 'Enter text', - tooltips: 'Help text', - belong_to_node_id: 'node-1', - }, - ] - - const { result } = renderHook(() => useConfigurations(variables)) - - expect(result.current.length).toBe(1) - expect(result.current[0]!.variable).toBe('textVar') - expect(result.current[0]!.label).toBe('Text Label') - expect(result.current[0]!.required).toBe(true) - expect(result.current[0]!.maxLength).toBe(100) - expect(result.current[0]!.placeholder).toBe('Enter text') - expect(result.current[0]!.tooltip).toBe('Help text') - }) - - it('should transform options correctly', () => { - const variables: RAGPipelineVariables = [ - { - type: 'select' as keyof typeof VAR_TYPE_MAP, - variable: 'selectVar', - label: 'Select', - required: false, - options: ['option1', 'option2', 'option3'], - belong_to_node_id: 'node-1', - }, - ] - - const { result } = renderHook(() => useConfigurations(variables)) - - expect(result.current[0]!.options).toEqual([ - { label: 'option1', value: 'option1' }, - { label: 'option2', value: 'option2' }, - { label: 'option3', value: 'option3' }, - ]) - }) -}) - -describe('useAvailableNodesMetaData', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return nodes array', () => { - const { result } = renderHook(() => useAvailableNodesMetaData()) - - expect(result.current.nodes).toBeDefined() - expect(Array.isArray(result.current.nodes)).toBe(true) - }) - - it('should return nodesMap object', () => { - const { result } = renderHook(() => useAvailableNodesMetaData()) - - expect(result.current.nodesMap).toBeDefined() - expect(typeof result.current.nodesMap).toBe('object') - }) -}) - -describe('usePipelineTemplate', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should return nodes array with knowledge base node', () => { - const { result } = renderHook(() => usePipelineTemplate()) - - expect(result.current.nodes).toBeDefined() - expect(Array.isArray(result.current.nodes)).toBe(true) - expect(result.current.nodes.length).toBe(1) - }) - - it('should return empty edges array', () => { - const { result } = renderHook(() => usePipelineTemplate()) - - expect(result.current.edges).toEqual([]) - }) -}) - -describe('exports', () => { - it('should export useAvailableNodesMetaData', () => { - expect(useAvailableNodesMetaData).toBeDefined() - }) - - it('should export useGetRunAndTraceUrl', () => { - expect(useGetRunAndTraceUrl).toBeDefined() - }) - - it('should export useInputFieldPanel', () => { - expect(useInputFieldPanel).toBeDefined() - }) - - it('should export useNodesSyncDraft', () => { - expect(useNodesSyncDraft).toBeDefined() - }) - - it('should export usePipelineInit', () => { - expect(usePipelineInit).toBeDefined() - }) - - it('should export usePipelineRefreshDraft', () => { - expect(usePipelineRefreshDraft).toBeDefined() - }) -}) - -afterEach(() => { - vi.clearAllMocks() -}) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts index 691d168a751..71322e3f3e9 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-inspect-vars-crud.spec.ts @@ -21,7 +21,7 @@ const mockApis = { } const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis) -vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({ +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({ useInspectVarsCrudCommon: (...args: Parameters) => mockUseInspectVarsCrudCommon(...args), })) diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts index f03700c20ee..b92978860c8 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -42,7 +42,7 @@ vi.mock('@/service/workflow', () => ({ })) const mockHandleRefreshWorkflowDraft = vi.fn() -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('../use-pipeline-refresh-draft', () => ({ usePipelineRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft, }), diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts index 40978e324a2..e98976f1acc 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts @@ -11,7 +11,7 @@ vi.mock('@/app/components/workflow/store', () => ({ })) const mockHandleUpdateWorkflowCanvas = vi.fn() -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, }), diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts index cef721bce08..2c7378493bb 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-run.spec.ts @@ -41,7 +41,7 @@ vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => })) const mockHandleUpdateWorkflowCanvas = vi.fn() -vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, }), diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts index f84203a957a..68b7f3abac4 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-start-run.spec.ts @@ -14,17 +14,20 @@ vi.mock('@/app/components/workflow/store', () => ({ })) const mockHandleCancelDebugAndPreviewPanel = vi.fn() -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({ useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, }), })) const mockDoSyncWorkflowDraft = vi.fn() -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('../use-nodes-sync-draft', () => ({ useNodesSyncDraftByCanEdit: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft, }), +})) + +vi.mock('../use-input-field-panel', () => ({ useInputFieldPanel: () => ({ closeAllInputFieldPanels: vi.fn(), }), diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts index 514576382c9..6fe02495f03 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline.spec.ts @@ -26,12 +26,12 @@ vi.mock('reactflow', () => ({ const mockFindUsedVarNodes = vi.fn() const mockUpdateNodeVars = vi.fn() -vi.mock('../../../workflow/nodes/_base/components/variable/utils', () => ({ +vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', () => ({ findUsedVarNodes: (...args: unknown[]) => mockFindUsedVarNodes(...args), updateNodeVars: (...args: unknown[]) => mockUpdateNodeVars(...args), })) -vi.mock('../../../workflow/types', () => ({ +vi.mock('@/app/components/workflow/types', () => ({ BlockEnum: { DataSource: 'data-source', }, diff --git a/web/app/components/rag-pipeline/hooks/index.ts b/web/app/components/rag-pipeline/hooks/index.ts deleted file mode 100644 index c7ad7f6f92a..00000000000 --- a/web/app/components/rag-pipeline/hooks/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './use-available-nodes-meta-data' -export * from './use-DSL' -export * from './use-get-run-and-trace-url' -export * from './use-input-field-panel' -export * from './use-nodes-sync-draft' -export * from './use-pipeline-init' -export * from './use-pipeline-refresh-draft' -export * from './use-pipeline-run' -export * from './use-pipeline-start-run' diff --git a/web/app/components/rag-pipeline/hooks/use-inspect-vars-crud.ts b/web/app/components/rag-pipeline/hooks/use-inspect-vars-crud.ts index 48c65e6b301..d35d84fa86e 100644 --- a/web/app/components/rag-pipeline/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/rag-pipeline/hooks/use-inspect-vars-crud.ts @@ -1,4 +1,4 @@ -import { useInspectVarsCrudCommon } from '../../workflow/hooks/use-inspect-vars-crud-common' +import { useInspectVarsCrudCommon } from '@/app/components/workflow/hooks/use-inspect-vars-crud-common' import { useConfigsMap } from './use-configs-map' export const useInspectVarsCrud = () => { diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index cf4ff6a8817..0c52fe4abc3 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -11,7 +11,7 @@ import { useWorkflowStore } from '@/app/components/workflow/store' import { API_PREFIX } from '@/config' import { postWithKeepalive } from '@/service/fetch' import { syncWorkflowDraft } from '@/service/workflow' -import { usePipelineRefreshDraft } from '.' +import { usePipelineRefreshDraft } from './use-pipeline-refresh-draft' const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { const store = useStoreApi() diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts index e02a21d7a22..3e7c5bcbd32 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts @@ -1,6 +1,6 @@ import type { WorkflowDataUpdater } from '@/app/components/workflow/types' import { useCallback } from 'react' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowStore } from '@/app/components/workflow/store' import { fetchWorkflowDraft } from '@/service/workflow' import { processNodesWithoutDataSource } from '../utils' diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts index 7652d62209e..e26a24f2a92 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts @@ -5,8 +5,8 @@ import { produce } from 'immer' import { useCallback, useRef } from 'react' import { useReactFlow, useStoreApi } from 'reactflow' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { ssePost } from '@/service/base' diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx b/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx index b7a5de93a6e..79a47adb54b 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-start-run.tsx @@ -1,9 +1,10 @@ -import type { useNodesSyncDraft } from '.' +import type { useNodesSyncDraft } from './use-nodes-sync-draft' import { useCallback } from 'react' -import { useWorkflowInteractions } from '@/app/components/workflow/hooks' +import { useWorkflowInteractions } from '@/app/components/workflow/hooks/use-workflow-panel-interactions' import { useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' -import { useInputFieldPanel, useNodesSyncDraftByCanEdit } from '.' +import { useInputFieldPanel } from './use-input-field-panel' +import { useNodesSyncDraftByCanEdit } from './use-nodes-sync-draft' type DoSyncWorkflowDraft = ReturnType['doSyncWorkflowDraft'] diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline.tsx b/web/app/components/rag-pipeline/hooks/use-pipeline.tsx index 42c610c4238..a4e54d1752c 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline.tsx +++ b/web/app/components/rag-pipeline/hooks/use-pipeline.tsx @@ -1,13 +1,13 @@ -import type { DataSourceNodeType } from '../../workflow/nodes/data-source/types' -import type { Node, ValueSelector } from '../../workflow/types' +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import type { Node, ValueSelector } from '@/app/components/workflow/types' import { uniqBy } from 'es-toolkit/compat' import { useCallback } from 'react' import { getOutgoers, useStoreApi } from 'reactflow' import { findUsedVarNodes, updateNodeVars, -} from '../../workflow/nodes/_base/components/variable/utils' -import { BlockEnum } from '../../workflow/types' +} from '@/app/components/workflow/nodes/_base/components/variable/utils' +import { BlockEnum } from '@/app/components/workflow/types' export const usePipeline = () => { const store = useStoreApi() diff --git a/web/app/components/rag-pipeline/index.tsx b/web/app/components/rag-pipeline/index.tsx index dd50fa25e7c..75c7e9bdfc8 100644 --- a/web/app/components/rag-pipeline/index.tsx +++ b/web/app/components/rag-pipeline/index.tsx @@ -7,7 +7,7 @@ import { initialEdges, initialNodes } from '@/app/components/workflow/utils' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import Conversion from './components/conversion' import RagPipelineMain from './components/rag-pipeline-main' -import { usePipelineInit } from './hooks' +import { usePipelineInit } from './hooks/use-pipeline-init' import { createRagPipelineSliceSlice } from './store' import { processNodesWithoutDataSource } from './utils' diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index a488e1b86b0..b9cb9c000cf 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -82,7 +82,7 @@ vi.mock('@/service/use-snippet-workflows', () => ({ }), })) -vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({ +vi.mock('../../hooks/use-configs-map', () => ({ useConfigsMap: () => ({ flowId: 'snippet-1', flowType: 'snippet', @@ -102,15 +102,15 @@ vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({ }), })) -vi.mock('@/app/components/workflow-app/hooks', () => ({ +vi.mock('@/app/components/workflow-app/hooks/use-available-nodes-meta-data', () => ({ useAvailableNodesMetaData: () => mockUseAvailableNodesMetaData(), })) -vi.mock('@/app/components/snippets/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ useInspectVarsCrud: () => mockInspectVarsCrud, })) -vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({ +vi.mock('../../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft, syncInputFieldsDraft: mockSyncInputFieldsDraft, @@ -118,13 +118,13 @@ vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({ }), })) -vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({ +vi.mock('../../hooks/use-snippet-refresh-draft', () => ({ useSnippetRefreshDraft: () => ({ handleRefreshWorkflowDraft: vi.fn(), }), })) -vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({ +vi.mock('../../hooks/use-snippet-run', () => ({ useSnippetRun: () => ({ handleBackupDraft: mockHandleBackupDraft, handleLoadBackupDraft: mockHandleLoadBackupDraft, @@ -134,7 +134,7 @@ vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({ }), })) -vi.mock('@/app/components/snippets/hooks/use-snippet-start-run', () => ({ +vi.mock('../../hooks/use-snippet-start-run', () => ({ useSnippetStartRun: () => ({ handleStartWorkflowRun: mockHandleStartWorkflowRun, handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow, diff --git a/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx index 36f214d5452..baad1550a05 100644 --- a/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-run-panel.spec.tsx @@ -35,10 +35,13 @@ vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({ useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: workflowHookMocks.handleCancelDebugAndPreviewPanel, }), +})) + +vi.mock('@/app/components/workflow/hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: workflowHookMocks.handleRun, }), diff --git a/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx b/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx index b29f0f6d182..d1c75ce93a2 100644 --- a/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx +++ b/web/app/components/snippets/components/snippet-header/__tests__/run-mode.spec.tsx @@ -21,15 +21,18 @@ const runningResult = { outputs_truncated: false, } -vi.mock('@/app/components/workflow/hooks', () => ({ - useWorkflowStartRun: () => ({ - handleWorkflowStartRunInWorkflow: workflowHookMocks.handleWorkflowStartRunInWorkflow, - }), +vi.mock('@/app/components/workflow/hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleStopRun: workflowHookMocks.handleStopRun, }), })) +vi.mock('@/app/components/workflow/hooks/use-workflow-start-run', () => ({ + useWorkflowStartRun: () => ({ + handleWorkflowStartRunInWorkflow: workflowHookMocks.handleWorkflowStartRunInWorkflow, + }), +})) + vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: { diff --git a/web/app/components/snippets/components/snippet-header/run-mode.tsx b/web/app/components/snippets/components/snippet-header/run-mode.tsx index 8da34b4ac4a..bce7af0988c 100644 --- a/web/app/components/snippets/components/snippet-header/run-mode.tsx +++ b/web/app/components/snippets/components/snippet-header/run-mode.tsx @@ -4,7 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks' +import { useWorkflowRun } from '@/app/components/workflow/hooks/use-workflow-run' +import { useWorkflowStartRun } from '@/app/components/workflow/hooks/use-workflow-start-run' import { TEST_RUN_MENU_HOTKEY } from '@/app/components/workflow/hotkeys' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' import { useStore } from '@/app/components/workflow/store' diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index ef954aafd7c..84d3a7ee542 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'reac import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { WorkflowWithInnerContext } from '@/app/components/workflow' -import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks' +import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks/use-available-nodes-meta-data' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' diff --git a/web/app/components/snippets/components/snippet-run-panel.tsx b/web/app/components/snippets/components/snippet-run-panel.tsx index d04f4351a81..718a4e6c28c 100644 --- a/web/app/components/snippets/components/snippet-run-panel.tsx +++ b/web/app/components/snippets/components/snippet-run-panel.tsx @@ -11,7 +11,8 @@ import { useTranslation } from 'react-i18next' import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks' import { getProcessedInputs } from '@/app/components/base/chat/chat/utils' import Loading from '@/app/components/base/loading' -import { useWorkflowInteractions, useWorkflowRun } from '@/app/components/workflow/hooks' +import { useWorkflowInteractions } from '@/app/components/workflow/hooks/use-workflow-panel-interactions' +import { useWorkflowRun } from '@/app/components/workflow/hooks/use-workflow-run' import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item' import ResultPanel from '@/app/components/workflow/run/result-panel' import ResultText from '@/app/components/workflow/run/result-text' diff --git a/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts b/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts index 0110c837ad4..26719bc3a70 100644 --- a/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-inspect-vars-crud.spec.ts @@ -20,7 +20,7 @@ const mockApis = { } const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis) -vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({ +vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({ useInspectVarsCrudCommon: (...args: Parameters) => mockUseInspectVarsCrudCommon(...args), })) diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts index 580783ed2f2..a7522ec0c37 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-refresh-draft.spec.ts @@ -19,7 +19,7 @@ vi.mock('@/service/use-snippet-workflows', () => ({ fetchSnippetDraftWorkflow: (...args: unknown[]) => mockFetchSnippetDraftWorkflow(...args), })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, }), diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts index 1511430ece3..6c0edfe37ae 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-run.spec.ts @@ -82,7 +82,7 @@ vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => }), })) -vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas, }), diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts index cc789a7d5c1..03597494ee1 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-start-run.spec.ts @@ -15,7 +15,7 @@ vi.mock('@/app/components/workflow/store', () => ({ })) const mockHandleCancelDebugAndPreviewPanel = vi.fn() -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({ useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, }), diff --git a/web/app/components/snippets/hooks/use-inspect-vars-crud.ts b/web/app/components/snippets/hooks/use-inspect-vars-crud.ts index 71ae6df0681..290e4761380 100644 --- a/web/app/components/snippets/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/snippets/hooks/use-inspect-vars-crud.ts @@ -1,4 +1,4 @@ -import { useInspectVarsCrudCommon } from '../../workflow/hooks/use-inspect-vars-crud-common' +import { useInspectVarsCrudCommon } from '@/app/components/workflow/hooks/use-inspect-vars-crud-common' import { useConfigsMap } from './use-configs-map' export const useInspectVarsCrud = (snippetId: string) => { diff --git a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts index f2192178358..348fe361d57 100644 --- a/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts +++ b/web/app/components/snippets/hooks/use-snippet-refresh-draft.ts @@ -2,7 +2,7 @@ import type { WorkflowDataUpdater } from '@/app/components/workflow/types' import type { SnippetInputField } from '@/models/snippet' import type { SnippetWorkflow } from '@/types/snippet' import { useCallback } from 'react' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowStore } from '@/app/components/workflow/store' import { fetchSnippetDraftWorkflow } from '@/service/use-snippet-workflows' import { useSnippetDraftStore } from '../draft-store' diff --git a/web/app/components/snippets/hooks/use-snippet-run.ts b/web/app/components/snippets/hooks/use-snippet-run.ts index e999ed80e44..b3fb9c18a47 100644 --- a/web/app/components/snippets/hooks/use-snippet-run.ts +++ b/web/app/components/snippets/hooks/use-snippet-run.ts @@ -5,8 +5,8 @@ import { produce } from 'immer' import { useCallback, useRef } from 'react' import { useReactFlow, useStoreApi } from 'reactflow' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { ssePost } from '@/service/base' diff --git a/web/app/components/snippets/hooks/use-snippet-start-run.ts b/web/app/components/snippets/hooks/use-snippet-start-run.ts index a9ddf381921..91e3f734ffe 100644 --- a/web/app/components/snippets/hooks/use-snippet-start-run.ts +++ b/web/app/components/snippets/hooks/use-snippet-start-run.ts @@ -1,6 +1,6 @@ import type { SnippetDraftRunPayload } from '@/types/snippet' import { useCallback } from 'react' -import { useWorkflowInteractions } from '@/app/components/workflow/hooks' +import { useWorkflowInteractions } from '@/app/components/workflow/hooks/use-workflow-panel-interactions' import { useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { useSnippetDraftStore } from '../draft-store' diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx index e7b99a0f57f..c0ffd014d50 100644 --- a/web/app/components/workflow-app/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -127,11 +127,11 @@ vi.mock('@/service/use-tools', () => ({ useAppTriggers: () => appTriggersState, })) -vi.mock('@/app/components/workflow-app/hooks/use-workflow-init', () => ({ +vi.mock('../hooks/use-workflow-init', () => ({ useWorkflowInit: () => workflowInitState, })) -vi.mock('@/app/components/workflow-app/hooks/use-get-run-and-trace-url', () => ({ +vi.mock('../hooks/use-get-run-and-trace-url', () => ({ useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: mockGetWorkflowRunAndTraceUrl, }), diff --git a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx index 7b0a350ca1a..ec01ea95da5 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-children.spec.tsx @@ -86,23 +86,37 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-DSL', () => ({ useAutoGenerateWebhookUrl: () => mockAutoGenerateWebhookUrl, useDSL: () => ({ exportCheck: mockExportCheck, handleExportDSL: mockHandleExportDSL, }), +})) + +vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({ usePanelInteractions: () => ({ handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, }), })) +vi.mock('@/app/components/workflow/hooks/use-panel-interactions', () => ({ + usePanelInteractions: () => ({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + handlePaneContextMenu: vi.fn(), + }), +})) + vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, }), })) +vi.mock('@/app/components/workflow/hooks/use-auto-generate-webhook-url', () => ({ + useAutoGenerateWebhookUrl: () => mockAutoGenerateWebhookUrl, +})) + vi.mock('@/app/components/workflow/utils', async (importOriginal) => { const actual = await importOriginal() return { @@ -120,7 +134,7 @@ vi.mock('@/app/components/workflow/utils', async (importOriginal) => { } }) -vi.mock('@/app/components/workflow-app/hooks', () => ({ +vi.mock('../../hooks/use-available-nodes-meta-data', () => ({ useAvailableNodesMetaData: () => ({ nodesMap: { [BlockEnum.Start]: { @@ -145,7 +159,7 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ }), })) -vi.mock('@/app/components/workflow-app/hooks/use-auto-onboarding', () => ({ +vi.mock('../../hooks/use-auto-onboarding', () => ({ useAutoOnboarding: () => ({ handleOnboardingClose: mockHandleOnboardingClose, }), diff --git a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx index 810dc214b17..72f9db49c6b 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx @@ -141,7 +141,7 @@ vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => }), })) -vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, }), @@ -274,18 +274,30 @@ vi.mock('@/app/components/workflow', () => ({ }, })) -vi.mock('@/app/components/workflow-app/hooks', () => ({ +vi.mock('../../hooks/use-available-nodes-meta-data', () => ({ useAvailableNodesMetaData: () => ({ nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } }, }), +})) + +vi.mock('../../hooks/use-configs-map', () => ({ useConfigsMap: () => ({ flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }), +})) + +vi.mock('../../hooks/use-DSL', () => ({ useDSL: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }), useDSLByCanEdit: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL, }), +})) + +vi.mock('../../hooks/use-get-run-and-trace-url', () => ({ useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl }), +})) + +vi.mock('../../hooks/use-inspect-vars-crud', () => ({ useInspectVarsCrud: () => ({ hasNodeInspectVars: hookFns.hasNodeInspectVars, hasSetInspectVar: hookFns.hasSetInspectVar, @@ -302,6 +314,9 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ resetConversationVar: hookFns.resetConversationVar, invalidateConversationVarValues: hookFns.invalidateConversationVarValues, }), +})) + +vi.mock('../../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose, @@ -310,12 +325,15 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose, }), - useSetWorkflowVarsWithValue: () => ({ - fetchInspectVars: hookFns.fetchInspectVars, - }), +})) + +vi.mock('../../hooks/use-workflow-refresh-draft', () => ({ useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft, }), +})) + +vi.mock('../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleBackupDraft: hookFns.handleBackupDraft, handleLoadBackupDraft: hookFns.handleLoadBackupDraft, @@ -330,6 +348,9 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ handleRun: hookFns.handleRun, handleStopRun: hookFns.handleStopRun, }), +})) + +vi.mock('../../hooks/use-workflow-start-run', () => ({ useWorkflowStartRun: () => ({ handleStartWorkflowRun: hookFns.handleStartWorkflowRun, handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow, @@ -350,7 +371,13 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({ }), })) -vi.mock('@/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas', () => ({ +vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({ + useSetWorkflowVarsWithValue: () => ({ + fetchInspectVars: hookFns.fetchInspectVars, + }), +})) + +vi.mock('../../hooks/use-workflow-draft-graph-for-canvas', () => ({ useWorkflowDraftGraphForCanvas: () => ({ getWorkflowDraftGraphForCanvas: (graph?: { nodes?: unknown[] @@ -366,7 +393,7 @@ vi.mock('@/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas }), })) -vi.mock('@/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas', () => ({ +vi.mock('../../hooks/use-workflow-draft-graph-for-canvas', () => ({ useWorkflowDraftGraphForCanvas: () => ({ getWorkflowDraftGraphForCanvas: (graph?: { nodes?: unknown[] diff --git a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx index cb569cc02db..61d1712e409 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-panel.spec.tsx @@ -130,7 +130,7 @@ vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({ default: () =>
global-variable
, })) -vi.mock('@/app/components/workflow-app/hooks', () => ({ +vi.mock('../../hooks/use-is-chat-mode', () => ({ useIsChatMode: () => mockUseIsChatMode(), })) diff --git a/web/app/components/workflow-app/components/workflow-children.tsx b/web/app/components/workflow-app/components/workflow-children.tsx index 89f09314786..d4558ed1f1f 100644 --- a/web/app/components/workflow-app/components/workflow-children.tsx +++ b/web/app/components/workflow-app/components/workflow-children.tsx @@ -6,21 +6,19 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { memo, useCallback, useState } from 'react' import { useStoreApi } from 'reactflow' import { DSL_EXPORT_CHECK, START_INITIAL_POSITION } from '@/app/components/workflow/constants' -import { - useAutoGenerateWebhookUrl, - useDSL, - usePanelInteractions, -} from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' +import { useAutoGenerateWebhookUrl } from '@/app/components/workflow/hooks/use-auto-generate-webhook-url' +import { useDSL } from '@/app/components/workflow/hooks/use-DSL' import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' +import { usePanelInteractions } from '@/app/components/workflow/hooks/use-panel-interactions' +import PluginDependency from '@/app/components/workflow/plugin-dependency' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { generateNewNode } from '@/app/components/workflow/utils' import { useEventEmitterContextContext } from '@/context/event-emitter' import dynamic from '@/next/dynamic' -import PluginDependency from '../../workflow/plugin-dependency' -import { useAvailableNodesMetaData } from '../hooks' import { useAutoOnboarding } from '../hooks/use-auto-onboarding' +import { useAvailableNodesMetaData } from '../hooks/use-available-nodes-meta-data' import WorkflowHeader from './workflow-header' import WorkflowPanel from './workflow-panel' diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/chat-variable-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/chat-variable-trigger.spec.tsx index 4d17f459759..343a6d26314 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/chat-variable-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/chat-variable-trigger.spec.tsx @@ -4,11 +4,11 @@ import ChatVariableTrigger from '../chat-variable-trigger' const mockUseNodesReadOnly = vi.fn() const mockUseIsChatMode = vi.fn() -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ useNodesReadOnly: () => mockUseNodesReadOnly(), })) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-is-chat-mode', () => ({ useIsChatMode: () => mockUseIsChatMode(), })) diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 9005f2f50dd..b81b513f48e 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -75,12 +75,18 @@ const mockWorkflowStore = { setState: mockWorkflowStoreSetState, } -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ + useNodesReadOnly: () => mockUseNodesReadOnly(), + useIsChatMode: () => mockUseIsChatMode(), +})) + +vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({ useChecklist: (...args: unknown[]) => mockUseChecklist(...args), useChecklistBeforePublish: () => mockUseChecklistBeforePublish(), - useNodesReadOnly: () => mockUseNodesReadOnly(), +})) + +vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => mockUseNodesSyncDraft(), - useIsChatMode: () => mockUseIsChatMode(), })) vi.mock('@/app/components/workflow/store', () => ({ diff --git a/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx index 92840d74a2d..e2709e2074e 100644 --- a/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/chat-variable-trigger.tsx @@ -1,7 +1,7 @@ import { memo } from 'react' import ChatVariableButton from '@/app/components/workflow/header/chat-variable-button' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' -import { useIsChatMode } from '../../hooks' +import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow' +import { useIsChatMode } from '../../hooks/use-is-chat-mode' const ChatVariableTrigger = () => { const { nodesReadOnly } = useNodesReadOnly() diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index 43b1828b73b..604d02fcb37 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -13,15 +13,14 @@ import { AppPublisher } from '@/app/components/app/app-publisher' import { useStore as useAppStore } from '@/app/components/app/store' import { useFeatures } from '@/app/components/base/features/hooks' import { Plan } from '@/app/components/billing/type' +// useWorkflowRunValidation, +import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useChecklist, useChecklistBeforePublish, - useIsChatMode, - useNodesReadOnly, - useNodesSyncDraft, - // useWorkflowRunValidation, -} from '@/app/components/workflow/hooks' -import { useHooksStore } from '@/app/components/workflow/hooks-store' +} from '@/app/components/workflow/hooks/use-checklist' +import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' +import { useIsChatMode, useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow' import { isAgentV2NodeData } from '@/app/components/workflow/nodes/agent-v2/types' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' diff --git a/web/app/components/workflow-app/components/workflow-header/index.tsx b/web/app/components/workflow-app/components/workflow-header/index.tsx index da2434f1ac2..2fe6b6325cf 100644 --- a/web/app/components/workflow-app/components/workflow-header/index.tsx +++ b/web/app/components/workflow-app/components/workflow-header/index.tsx @@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import Header from '@/app/components/workflow/header' import { useResetWorkflowVersionHistory } from '@/service/use-workflow' -import { useIsChatMode } from '../../hooks' +import { useIsChatMode } from '../../hooks/use-is-chat-mode' import ChatVariableTrigger from './chat-variable-trigger' import FeaturesTrigger from './features-trigger' diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index 449f4f66e27..c218d14cac8 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -12,28 +12,26 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { WorkflowWithInnerContext } from '@/app/components/workflow' -import { useWorkflowDraftGraphForCanvas } from '@/app/components/workflow-app/hooks/use-workflow-draft-graph-for-canvas' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' +import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { fetchWorkflowDraft } from '@/service/workflow' import { getAppACLCapabilities } from '@/utils/permission' -import { - useAvailableNodesMetaData, - useConfigsMap, - useDSLByCanEdit, - useGetRunAndTraceUrl, - useInspectVarsCrud, - useNodesSyncDraftByCanEdit, - useSetWorkflowVarsWithValue, - useWorkflowRefreshDraft, - useWorkflowRunByCanEdit, - useWorkflowStartRunByCanEdit, -} from '../hooks' +import { useAvailableNodesMetaData } from '../hooks/use-available-nodes-meta-data' +import { useConfigsMap } from '../hooks/use-configs-map' +import { useDSLByCanEdit } from '../hooks/use-DSL' +import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url' +import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud' +import { useNodesSyncDraftByCanEdit } from '../hooks/use-nodes-sync-draft' +import { useWorkflowDraftGraphForCanvas } from '../hooks/use-workflow-draft-graph-for-canvas' +import { useWorkflowRefreshDraft } from '../hooks/use-workflow-refresh-draft' +import { useWorkflowRunByCanEdit } from '../hooks/use-workflow-run' +import { useWorkflowStartRunByCanEdit } from '../hooks/use-workflow-start-run' import WorkflowChildren from './workflow-children' type WorkflowMainProps = Pick diff --git a/web/app/components/workflow-app/components/workflow-panel.tsx b/web/app/components/workflow-app/components/workflow-panel.tsx index 4e7abbfef84..2636a24a87b 100644 --- a/web/app/components/workflow-app/components/workflow-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-panel.tsx @@ -6,7 +6,7 @@ import Panel from '@/app/components/workflow/panel' import CommentsPanel from '@/app/components/workflow/panel/comments-panel' import { useStore } from '@/app/components/workflow/store' import dynamic from '@/next/dynamic' -import { useIsChatMode } from '../hooks' +import { useIsChatMode } from '../hooks/use-is-chat-mode' const MessageLogModal = dynamic(() => import('@/app/components/base/message-log-modal'), { ssr: false, diff --git a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts index e6e0927a6bf..2354776da63 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-available-nodes-meta-data.spec.ts @@ -5,7 +5,7 @@ import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data' const mockUseIsChatMode = vi.fn() const mockIsAgentV2Enabled = vi.hoisted(() => vi.fn(() => true)) -vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({ +vi.mock('../use-is-chat-mode', () => ({ useIsChatMode: () => mockUseIsChatMode(), })) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index 90e9c27cc48..27a475f093e 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -93,7 +93,7 @@ vi.mock('@/config', async (importOriginal) => { }) const mockHandleRefreshWorkflowDraft = vi.fn() -vi.mock('@/app/components/workflow-app/hooks', () => ({ +vi.mock('../use-workflow-refresh-draft', () => ({ useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }), })) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts index f94134b8b4a..db94d06d73c 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-refresh-draft.spec.ts @@ -40,7 +40,7 @@ vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: typeof appStoreState) => T): T => selector(appStoreState), })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas }), })) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts index 230ad3ea63b..74c24c9ac83 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts @@ -154,7 +154,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ useWorkflowUpdate: () => ({ handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas, }), diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx b/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx index 6943f8b9aa0..30339007207 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-start-run.spec.tsx @@ -37,7 +37,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow-panel-interactions', () => ({ useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, }), @@ -49,11 +49,17 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/app/components/workflow-app/hooks', () => ({ +vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ useIsChatMode: () => mockUseIsChatMode(), +})) + +vi.mock('../use-nodes-sync-draft', () => ({ useNodesSyncDraftByCanEdit: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft, }), +})) + +vi.mock('../use-workflow-run', () => ({ useWorkflowRunByCanEdit: () => ({ handleRun: mockHandleRun, }), diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts index 930c8e66971..bb96a226665 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-template.spec.ts @@ -10,7 +10,7 @@ let appStoreState: { } } -vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({ +vi.mock('../use-is-chat-mode', () => ({ useIsChatMode: () => mockUseIsChatMode(), })) diff --git a/web/app/components/workflow-app/hooks/index.ts b/web/app/components/workflow-app/hooks/index.ts deleted file mode 100644 index 2a8c8068596..00000000000 --- a/web/app/components/workflow-app/hooks/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * from '../../workflow/hooks/use-fetch-workflow-inspect-vars' -export * from './use-available-nodes-meta-data' -export * from './use-configs-map' -export * from './use-DSL' -export * from './use-get-run-and-trace-url' -export * from './use-inspect-vars-crud' -export * from './use-is-chat-mode' -export * from './use-nodes-sync-draft' -export * from './use-workflow-init' -export * from './use-workflow-refresh-draft' -export * from './use-workflow-run' -export * from './use-workflow-start-run' -export * from './use-workflow-template' diff --git a/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts b/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts index 48c65e6b301..d35d84fa86e 100644 --- a/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts +++ b/web/app/components/workflow-app/hooks/use-inspect-vars-crud.ts @@ -1,4 +1,4 @@ -import { useInspectVarsCrudCommon } from '../../workflow/hooks/use-inspect-vars-crud-common' +import { useInspectVarsCrudCommon } from '@/app/components/workflow/hooks/use-inspect-vars-crud-common' import { useConfigsMap } from './use-configs-map' export const useInspectVarsCrud = () => { diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 6767f77c628..028907cba4a 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -25,7 +25,7 @@ import { API_PREFIX } from '@/config' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { postWithKeepalive } from '@/service/fetch' import { syncWorkflowDraft } from '@/service/workflow' -import { useWorkflowRefreshDraft } from '.' +import { useWorkflowRefreshDraft } from './use-workflow-refresh-draft' const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { const store = useStoreApi() diff --git a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts index 834a43eebea..d68d3c4cc37 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts @@ -1,6 +1,6 @@ import { useCallback, useRef } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowStore } from '@/app/components/workflow/store' import { fetchWorkflowDraft } from '@/service/workflow' import { useWorkflowDraftGraphForCanvas } from './use-workflow-draft-graph-for-canvas' diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index ae199e8cd9c..30626962983 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -13,15 +13,15 @@ import { trackEvent } from '@/app/components/base/amplitude' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { TriggerType } from '@/app/components/workflow/header/test-run-menu' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' +import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-update' import { useWorkflowStore } from '@/app/components/workflow/store' import { usePathname } from '@/next/navigation' import { ssePost } from '@/service/base' import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow' import { stopWorkflowRun } from '@/service/workflow' import { AppModeEnum } from '@/types/app' -import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars' import { useConfigsMap } from './use-configs-map' import { useNodesSyncDraft, useNodesSyncDraftByCanEdit } from './use-nodes-sync-draft' import { diff --git a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx index 0e8440791aa..7f85ef37f5b 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx +++ b/web/app/components/workflow-app/hooks/use-workflow-start-run.tsx @@ -1,12 +1,15 @@ -import type { useNodesSyncDraft, useWorkflowRun } from '.' +import type { useNodesSyncDraft } from './use-nodes-sync-draft' +import type { useWorkflowRun } from './use-workflow-run' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { TriggerType } from '@/app/components/workflow/header/test-run-menu' -import { useWorkflowInteractions } from '@/app/components/workflow/hooks' +import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow' +import { useWorkflowInteractions } from '@/app/components/workflow/hooks/use-workflow-panel-interactions' import { useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum, WorkflowRunningStatus } from '@/app/components/workflow/types' -import { useIsChatMode, useNodesSyncDraftByCanEdit, useWorkflowRunByCanEdit } from '.' +import { useNodesSyncDraftByCanEdit } from './use-nodes-sync-draft' +import { useWorkflowRunByCanEdit } from './use-workflow-run' type HandleRun = ReturnType['handleRun'] type DoSyncWorkflowDraft = ReturnType['doSyncWorkflowDraft'] diff --git a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx index 3e8aafa489d..09bd67c188e 100644 --- a/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx +++ b/web/app/components/workflow/__tests__/candidate-node-main.spec.tsx @@ -47,11 +47,20 @@ vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => mockUseWorkflowStore(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesInteractions: () => mockUseHooks().useNodesInteractions(), - useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(), - useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(), +vi.mock('../hooks/use-auto-generate-webhook-url', () => ({ useAutoGenerateWebhookUrl: () => mockUseHooks().useAutoGenerateWebhookUrl(), +})) + +vi.mock('../hooks/use-nodes-interactions', () => ({ + useNodesInteractions: () => mockUseHooks().useNodesInteractions(), +})) + +vi.mock('../hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => mockUseHooks().useNodesSyncDraft(), +})) + +vi.mock('../hooks/use-workflow-history', () => ({ + useWorkflowHistory: () => mockUseHooks().useWorkflowHistory(), WorkflowHistoryEvent: { NodeAdd: 'NodeAdd', NoteAdd: 'NoteAdd', diff --git a/web/app/components/workflow/__tests__/custom-edge.spec.tsx b/web/app/components/workflow/__tests__/custom-edge.spec.tsx index b7459679245..eaff72bfe0c 100644 --- a/web/app/components/workflow/__tests__/custom-edge.spec.tsx +++ b/web/app/components/workflow/__tests__/custom-edge.spec.tsx @@ -41,10 +41,23 @@ vi.mock('reactflow', () => ({ }, })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args), - useNodesInteractions: () => mockUseNodesInteractions(), -})) +vi.mock('../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useAvailableBlocks: (...args: unknown[]) => mockUseAvailableBlocks(...args), + } +}) + +vi.mock('../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesInteractions: () => mockUseNodesInteractions(), + } +}) vi.mock('@/app/components/workflow/block-selector', () => ({ __esModule: true, diff --git a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx index dbb8e33ca6b..879f93fce7a 100644 --- a/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/edge-contextmenu.spec.tsx @@ -35,16 +35,6 @@ vi.mock('../utils', async (importOriginal) => { } }) -vi.mock('../hooks', async () => { - const { useEdgesInteractions } = await import('../hooks/use-edges-interactions') - const { usePanelInteractions } = await import('../hooks/use-panel-interactions') - - return { - useEdgesInteractions, - usePanelInteractions, - } -}) - type EdgeRuntimeState = { _hovering?: boolean _isBundled?: boolean diff --git a/web/app/components/workflow/__tests__/features.spec.tsx b/web/app/components/workflow/__tests__/features.spec.tsx index 5dee84b1e28..5d9da98e74c 100644 --- a/web/app/components/workflow/__tests__/features.spec.tsx +++ b/web/app/components/workflow/__tests__/features.spec.tsx @@ -31,8 +31,8 @@ const mockFeaturesStore = { let mockIsChatMode = true let mockNodesReadOnly = false -vi.mock('../hooks', async () => { - const actual = await vi.importActual('../hooks') +vi.mock('../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useIsChatMode: () => mockIsChatMode, diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx index 478d722104c..1a6fdf7ca4d 100644 --- a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -24,17 +24,78 @@ vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useAvailableBlocks: () => mockUseAvailableBlocks(), - useDSL: () => mockUseDSL(), - useIsChatMode: () => mockUseIsChatMode(), - useNodesInteractions: () => mockUseNodesInteractions(), - useNodesMetaData: () => mockUseNodesMetaData(), - useNodesReadOnly: () => mockUseNodesReadOnly(), - usePanelInteractions: () => mockUsePanelInteractions(), - useWorkflowMoveMode: () => mockUseWorkflowMoveMode(), - useWorkflowStartRun: () => mockUseWorkflowStartRun(), -})) +vi.mock('../hooks/use-DSL', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useDSL: () => mockUseDSL(), + } +}) + +vi.mock('../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useAvailableBlocks: () => mockUseAvailableBlocks(), + } +}) + +vi.mock('../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesInteractions: () => mockUseNodesInteractions(), + } +}) + +vi.mock('../hooks/use-nodes-meta-data', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesMetaData: () => mockUseNodesMetaData(), + } +}) + +vi.mock('../hooks/use-panel-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + usePanelInteractions: () => mockUsePanelInteractions(), + } +}) + +vi.mock('../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => mockUseIsChatMode(), + useNodesReadOnly: () => mockUseNodesReadOnly(), + } +}) + +vi.mock('../hooks/use-workflow-panel-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowMoveMode: () => mockUseWorkflowMoveMode(), + } +}) + +vi.mock('../hooks/use-workflow-start-run', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowStartRun: () => mockUseWorkflowStartRun(), + } +}) vi.mock('@/app/components/workflow/operator/hooks', () => ({ useOperator: () => mockUseOperator(), diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx index 39b0dd3a93c..105f9089f1c 100644 --- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx @@ -65,13 +65,20 @@ vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ }, })) -vi.mock('../hooks', async () => { - const actual = await vi.importActual('../hooks') +vi.mock('../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly, }), + } +}) + +vi.mock('../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, useNodesInteractions: () => ({ handleNodesCopy: mockHandleNodesCopy, handleNodesDuplicate: mockHandleNodesDuplicate, diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 6e188a05b80..ac8e4c9ef29 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -374,13 +374,16 @@ vi.mock('../shortcuts/use-workflow-hotkeys', () => ({ useWorkflowHotkeys: workflowHookMocks.useShortcuts, })) -vi.mock('../hooks', () => ({ +vi.mock('../hooks/use-edges-interactions', () => ({ useEdgesInteractions: () => ({ handleEdgeEnter: workflowHookMocks.handleEdgeEnter, handleEdgeLeave: workflowHookMocks.handleEdgeLeave, handleEdgesChange: workflowHookMocks.handleEdgesChange, handleEdgeContextMenu: workflowHookMocks.handleEdgeContextMenu, }), +})) + +vi.mock('../hooks/use-nodes-interactions', () => ({ useNodesInteractions: () => ({ handleNodesCopy: vi.fn(), handleNodesDelete: vi.fn(), @@ -399,43 +402,69 @@ vi.mock('../hooks', () => ({ handleHistoryBack: workflowHookMocks.handleHistoryBack, handleHistoryForward: workflowHookMocks.handleHistoryForward, }), +})) + +vi.mock('../hooks/use-workflow', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: false, getNodesReadOnly: () => false, }), - useNodesSyncDraft: () => ({ - handleSyncWorkflowDraft: workflowHookMocks.handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose: vi.fn(), - }), - usePanelInteractions: () => ({ - handlePaneContextMenu: workflowHookMocks.handlePaneContextMenu, - }), - useDSL: () => ({ - exportCheck: vi.fn(), - }), useIsChatMode: () => false, - useSelectionInteractions: () => ({ - handleSelectionStart: workflowHookMocks.handleSelectionStart, - handleSelectionChange: workflowHookMocks.handleSelectionChange, - handleSelectionDrag: workflowHookMocks.handleSelectionDrag, - handleSelectionContextMenu: workflowHookMocks.handleSelectionContextMenu, - }), - useSetWorkflowVarsWithValue: () => ({ - fetchInspectVars: workflowHookMocks.fetchInspectVars, - }), - useShortcuts: workflowHookMocks.useShortcuts, useWorkflow: () => ({ isValidConnection: workflowHookMocks.isValidConnection, }), useWorkflowReadOnly: () => ({ workflowReadOnly: false, }), +})) + +vi.mock('../hooks/use-nodes-sync-draft', () => ({ + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: workflowHookMocks.handleSyncWorkflowDraft, + syncWorkflowDraftWhenPageClose: vi.fn(), + }), +})) + +vi.mock('../hooks/use-panel-interactions', () => ({ + usePanelInteractions: () => ({ + handlePaneContextMenu: workflowHookMocks.handlePaneContextMenu, + }), +})) + +vi.mock('../hooks/use-DSL', () => ({ + useDSL: () => ({ + exportCheck: vi.fn(), + }), +})) + +vi.mock('../hooks/use-selection-interactions', () => ({ + useSelectionInteractions: () => ({ + handleSelectionStart: workflowHookMocks.handleSelectionStart, + handleSelectionChange: workflowHookMocks.handleSelectionChange, + handleSelectionDrag: workflowHookMocks.handleSelectionDrag, + handleSelectionContextMenu: workflowHookMocks.handleSelectionContextMenu, + }), +})) + +vi.mock('../hooks/use-set-workflow-vars-with-value', () => ({ + useSetWorkflowVarsWithValue: () => ({ + fetchInspectVars: workflowHookMocks.fetchInspectVars, + }), +})) + +vi.mock('../hooks/use-workflow-panel-interactions', () => ({ useWorkflowMoveMode: () => ({ isCommentModeAvailable: false, }), +})) + +vi.mock('../hooks/use-workflow-refresh-draft', () => ({ useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: vi.fn(), }), +})) + +vi.mock('../hooks/use-workflow-start-run', () => ({ useWorkflowStartRun: () => ({ handleStartWorkflowRun: vi.fn(), handleWorkflowStartRunInChatflow: vi.fn(), diff --git a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx index f4c769df7b5..ecb901cc0e6 100644 --- a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx @@ -5,13 +5,13 @@ import userEvent from '@testing-library/user-event' import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query' import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' +import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks/use-available-nodes-meta-data' import { useGetLanguage, useLocale } from '@/context/i18n' import useTheme from '@/hooks/use-theme' import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' import { renderWithConsoleQuery } from '@/test/console/query-data' import { Theme } from '@/types/app' -import { useAvailableNodesMetaData } from '../../../workflow-app/hooks' import useNodes from '../../store/workflow/use-nodes' import { BlockEnum } from '../../types' import AllStartBlocks from '../all-start-blocks' @@ -43,7 +43,7 @@ vi.mock('../../store/workflow/use-nodes', () => ({ default: vi.fn(), })) -vi.mock('../../../workflow-app/hooks', () => ({ +vi.mock('@/app/components/workflow-app/hooks/use-available-nodes-meta-data', () => ({ useAvailableNodesMetaData: vi.fn(), })) diff --git a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx index 7ec86860008..bf757ae1a05 100644 --- a/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/start-blocks.spec.tsx @@ -1,8 +1,8 @@ import type { CommonNodeType } from '../../types' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks/use-available-nodes-meta-data' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' -import { useAvailableNodesMetaData } from '../../../workflow-app/hooks' import { BlockEnum } from '../../types' import StartBlocks from '../start-blocks' @@ -10,7 +10,7 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ default: vi.fn(), })) -vi.mock('../../../workflow-app/hooks', () => ({ +vi.mock('@/app/components/workflow-app/hooks/use-available-nodes-meta-data', () => ({ useAvailableNodesMetaData: vi.fn(), })) diff --git a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx index be5559e79f3..9ec90476499 100644 --- a/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx +++ b/web/app/components/workflow/block-selector/snippets/__tests__/use-insert-snippet.spec.tsx @@ -59,10 +59,13 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, }), +})) + +vi.mock('../../../hooks/use-workflow-history', () => ({ useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory, }), diff --git a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts index fc7ed250ab7..0e2e5b5de98 100644 --- a/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts +++ b/web/app/components/workflow/block-selector/snippets/use-insert-snippet.ts @@ -7,7 +7,8 @@ import { useStoreApi } from 'reactflow' import { consoleQuery } from '@/service/client' import { useIncrementSnippetUseCountMutation } from '@/service/use-snippets' import { CUSTOM_EDGE, NESTED_ELEMENT_Z_INDEX, NODE_WIDTH_X_OFFSET, X_OFFSET } from '../../constants' -import { useNodesSyncDraft, useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks' +import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft' +import { useWorkflowHistory, WorkflowHistoryEvent } from '../../hooks/use-workflow-history' import { BlockEnum } from '../../types' import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../../utils' diff --git a/web/app/components/workflow/candidate-node-main.tsx b/web/app/components/workflow/candidate-node-main.tsx index 2d4af22960c..79ac1184cd0 100644 --- a/web/app/components/workflow/candidate-node-main.tsx +++ b/web/app/components/workflow/candidate-node-main.tsx @@ -4,15 +4,12 @@ import { useEventListener } from 'ahooks' import { produce } from 'immer' import { memo } from 'react' import { useReactFlow, useViewport } from 'reactflow' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { CUSTOM_NODE } from './constants' -import { - useAutoGenerateWebhookUrl, - useNodesInteractions, - useNodesSyncDraft, - useWorkflowHistory, - WorkflowHistoryEvent, -} from './hooks' +import { useAutoGenerateWebhookUrl } from './hooks/use-auto-generate-webhook-url' +import { useCollaborativeWorkflow } from './hooks/use-collaborative-workflow' +import { useNodesInteractions } from './hooks/use-nodes-interactions' +import { useNodesSyncDraft } from './hooks/use-nodes-sync-draft' +import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history' import CustomNode from './nodes' import { useCreateInlineAgentBinding } from './nodes/agent-v2/hooks' import { isAgentV2NodeData, needsInlineAgentBindingCreation } from './nodes/agent-v2/types' diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index 871b0b4f7c4..f5803064c00 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -7,7 +7,8 @@ import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/compo import BlockSelector from './block-selector' import { NESTED_ELEMENT_Z_INDEX } from './constants' import CustomEdgeLinearGradientRender from './custom-edge-linear-gradient-render' -import { useAvailableBlocks, useNodesInteractions } from './hooks' +import { useAvailableBlocks } from './hooks/use-available-blocks' +import { useNodesInteractions } from './hooks/use-nodes-interactions' import { NodeRunningStatus } from './types' import { getEdgeColor } from './utils' diff --git a/web/app/components/workflow/edge-contextmenu.tsx b/web/app/components/workflow/edge-contextmenu.tsx index ff351020f7c..8c87472a2e9 100644 --- a/web/app/components/workflow/edge-contextmenu.tsx +++ b/web/app/components/workflow/edge-contextmenu.tsx @@ -1,7 +1,7 @@ import { ContextMenuContent, ContextMenuItem } from '@langgenius/dify-ui/context-menu' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' -import { useEdgesInteractions } from './hooks' +import { useEdgesInteractions } from './hooks/use-edges-interactions' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore } from './store' diff --git a/web/app/components/workflow/features.tsx b/web/app/components/workflow/features.tsx index f466ab1a580..91150b44f88 100644 --- a/web/app/components/workflow/features.tsx +++ b/web/app/components/workflow/features.tsx @@ -8,7 +8,7 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks' import NewFeaturePanel from '@/app/components/base/features/new-feature-panel' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { updateFeatures } from '@/service/workflow' -import { useIsChatMode, useNodesReadOnly } from './hooks' +import { useIsChatMode, useNodesReadOnly } from './hooks/use-workflow' import useConfig from './nodes/start/use-config' import { useStore } from './store' import { InputVarType } from './types' diff --git a/web/app/components/workflow/header/__tests__/env-button.spec.tsx b/web/app/components/workflow/header/__tests__/env-button.spec.tsx index 0f579d37a0f..beae77fa277 100644 --- a/web/app/components/workflow/header/__tests__/env-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/env-button.spec.tsx @@ -11,7 +11,7 @@ vi.mock('@/hooks/use-theme', () => ({ }), })) -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('@/app/components/rag-pipeline/hooks/use-input-field-panel', () => ({ useInputFieldPanel: () => ({ closeAllInputFieldPanels: mockCloseAllInputFieldPanels, }), diff --git a/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx index 33df5bc4d8b..a5010bd6ce2 100644 --- a/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx +++ b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx @@ -11,7 +11,7 @@ vi.mock('@/hooks/use-theme', () => ({ }), })) -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('@/app/components/rag-pipeline/hooks/use-input-field-panel', () => ({ useInputFieldPanel: () => ({ closeAllInputFieldPanels: mockCloseAllInputFieldPanels, }), diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx index a08079b708e..06d5070759b 100644 --- a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx @@ -58,10 +58,13 @@ vi.mock('@/service/use-workflow', () => ({ }), })) -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleLoadBackupDraft: mockHandleLoadBackupDraft, }), +})) + +vi.mock('../../hooks/use-workflow-refresh-draft', () => ({ useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft, }), diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx index cf999af8fcd..1c63792f9bb 100644 --- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -41,22 +41,34 @@ vi.mock('reactflow', () => ({ useNodes: () => mockUseNodes(), })) -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-workflow', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }), +})) + +vi.mock('../../hooks/use-nodes-interactions', () => ({ useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect }), +})) + +vi.mock('../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleBackupDraft: mockHandleBackupDraft, handleLoadBackupDraft: mockHandleLoadBackupDraft, }), +})) + +vi.mock('../../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn(), }), +})) + +vi.mock('../../hooks/use-workflow-refresh-draft', () => ({ useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft, }), })) -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('@/app/components/rag-pipeline/hooks/use-input-field-panel', () => ({ useInputFieldPanel: () => ({ closeAllInputFieldPanels: mockCloseAllInputFieldPanels, }), diff --git a/web/app/components/workflow/header/__tests__/index.spec.tsx b/web/app/components/workflow/header/__tests__/index.spec.tsx index aeb5fd13478..759a4714f24 100644 --- a/web/app/components/workflow/header/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/__tests__/index.spec.tsx @@ -27,7 +27,7 @@ function DynamicHeaderRestoring(props: Record) { ) } -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-workflow-mode', () => ({ useWorkflowMode: () => mockWorkflowMode, })) diff --git a/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx b/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx index f75637df96b..eb817564b31 100644 --- a/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-and-history.spec.tsx @@ -8,8 +8,11 @@ const mockState = vi.hoisted(() => ({ const mockRunMode = vi.hoisted(() => vi.fn()) const mockHandleWorkflowStartRunInChatflow = vi.hoisted(() => vi.fn()) -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-workflow', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: mockState.nodesReadOnly }), +})) + +vi.mock('../../hooks/use-workflow-start-run', () => ({ useWorkflowStartRun: () => ({ handleWorkflowStartRunInChatflow: mockHandleWorkflowStartRunInChatflow, }), diff --git a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx index 6544d93f874..2e3c6720656 100644 --- a/web/app/components/workflow/header/__tests__/run-mode.spec.tsx +++ b/web/app/components/workflow/header/__tests__/run-mode.spec.tsx @@ -33,21 +33,42 @@ let mockIsListening = false let mockCanRun = true let mockDynamicOptions = [{ type: TriggerType.UserInput, nodeId: 'start-node' }] -vi.mock('@/app/components/workflow/hooks', () => ({ - useWorkflowStartRun: () => ({ - handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow, - handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow, - handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow, - handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow, - handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow, - }), - useWorkflowRun: () => ({ - handleStopRun: mockHandleStopRun, - }), - useWorkflowRunValidation: () => ({ - warningNodes: mockWarningNodes, - }), -})) +vi.mock('../../hooks/use-checklist', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowRunValidation: () => ({ + warningNodes: mockWarningNodes, + }), + } +}) + +vi.mock('../../hooks/use-workflow-run', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowRun: () => ({ + handleStopRun: mockHandleStopRun, + }), + } +}) + +vi.mock('../../hooks/use-workflow-start-run', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowStartRun: () => ({ + handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow, + handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow, + handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow, + handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow, + handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow, + }), + } +}) vi.mock('@/app/components/workflow/store/workflow', () => ({ useStore: ( diff --git a/web/app/components/workflow/header/__tests__/running-title.spec.tsx b/web/app/components/workflow/header/__tests__/running-title.spec.tsx index 7d904ed74a2..bdc62573a2c 100644 --- a/web/app/components/workflow/header/__tests__/running-title.spec.tsx +++ b/web/app/components/workflow/header/__tests__/running-title.spec.tsx @@ -4,7 +4,7 @@ import RunningTitle from '../running-title' let mockIsChatMode = false const mockFormatWorkflowRunIdentifier = vi.fn() -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-workflow', () => ({ useIsChatMode: () => mockIsChatMode, })) diff --git a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx index 4f3b6616760..18a4f79b38d 100644 --- a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx +++ b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx @@ -15,11 +15,16 @@ vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({ default: () =>
, })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ - nodesReadOnly: mockNodesReadOnly, - }), -})) +vi.mock('../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ + nodesReadOnly: mockNodesReadOnly, + }), + } +}) vi.mock('@/app/components/workflow/workflow-history-store', () => ({ useWorkflowHistoryStore: () => ({ diff --git a/web/app/components/workflow/header/__tests__/view-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-history.spec.tsx index 1d8dec6beeb..e2b4553fef3 100644 --- a/web/app/components/workflow/header/__tests__/view-history.spec.tsx +++ b/web/app/components/workflow/header/__tests__/view-history.spec.tsx @@ -17,20 +17,27 @@ const mockFormatWorkflowRunIdentifier = vi.fn( let mockIsChatMode = false -vi.mock('../../hooks', () => { - return { - useIsChatMode: () => mockIsChatMode, - useNodesInteractions: () => ({ - handleNodesCancelSelected: mockHandleNodesCancelSelected, - }), - useWorkflowInteractions: () => ({ - handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, - }), - useWorkflowRun: () => ({ - handleBackupDraft: mockHandleBackupDraft, - }), - } -}) +vi.mock('../../hooks/use-workflow', () => ({ + useIsChatMode: () => mockIsChatMode, +})) + +vi.mock('../../hooks/use-nodes-interactions', () => ({ + useNodesInteractions: () => ({ + handleNodesCancelSelected: mockHandleNodesCancelSelected, + }), +})) + +vi.mock('../../hooks/use-workflow-panel-interactions', () => ({ + useWorkflowInteractions: () => ({ + handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, + }), +})) + +vi.mock('../../hooks/use-workflow-run', () => ({ + useWorkflowRun: () => ({ + handleBackupDraft: mockHandleBackupDraft, + }), +})) vi.mock('@/service/use-workflow', () => ({ useWorkflowRunHistory: (url?: string, enabled?: boolean) => @@ -43,7 +50,7 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ }), })) -vi.mock('@/app/components/rag-pipeline/hooks', () => ({ +vi.mock('@/app/components/rag-pipeline/hooks/use-input-field-panel', () => ({ useInputFieldPanel: () => ({ closeAllInputFieldPanels: mockCloseAllInputFieldPanels, }), diff --git a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx index fcafc724c11..9255d13bd3b 100644 --- a/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx +++ b/web/app/components/workflow/header/checklist/__tests__/index.spec.tsx @@ -40,8 +40,11 @@ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({ default: () => [], })) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-checklist', () => ({ useChecklist: () => mockChecklistItems, +})) + +vi.mock('../../../hooks/use-nodes-interactions', () => ({ useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect, }), diff --git a/web/app/components/workflow/header/checklist/index.tsx b/web/app/components/workflow/header/checklist/index.tsx index bad1fc3162e..da1e1b4ee80 100644 --- a/web/app/components/workflow/header/checklist/index.tsx +++ b/web/app/components/workflow/header/checklist/index.tsx @@ -13,8 +13,9 @@ import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEdges } from 'reactflow' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' -import { useChecklist, useNodesInteractions } from '../../hooks' import { useHooksStore } from '../../hooks-store/store' +import { useChecklist } from '../../hooks/use-checklist' +import { useNodesInteractions } from '../../hooks/use-nodes-interactions' import { ChecklistNodeGroup } from './node-group' import { ChecklistPluginGroup } from './plugin-group' diff --git a/web/app/components/workflow/header/env-button.tsx b/web/app/components/workflow/header/env-button.tsx index 9859abfebee..1d52b04fe6e 100644 --- a/web/app/components/workflow/header/env-button.tsx +++ b/web/app/components/workflow/header/env-button.tsx @@ -2,7 +2,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { Env } from '@/app/components/base/icons/src/vender/line/others' -import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' +import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel' import { useStore } from '@/app/components/workflow/store' import useTheme from '@/hooks/use-theme' diff --git a/web/app/components/workflow/header/global-variable-button.tsx b/web/app/components/workflow/header/global-variable-button.tsx index 091aca99b97..ec335c7a9f5 100644 --- a/web/app/components/workflow/header/global-variable-button.tsx +++ b/web/app/components/workflow/header/global-variable-button.tsx @@ -2,7 +2,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { GlobalVariable } from '@/app/components/base/icons/src/vender/line/others' -import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' +import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel' import { useStore } from '@/app/components/workflow/store' import useTheme from '@/hooks/use-theme' diff --git a/web/app/components/workflow/header/header-in-normal.tsx b/web/app/components/workflow/header/header-in-normal.tsx index 643265d6d4f..132984cc3fa 100644 --- a/web/app/components/workflow/header/header-in-normal.tsx +++ b/web/app/components/workflow/header/header-in-normal.tsx @@ -2,10 +2,12 @@ import type { StartNodeType } from '../nodes/start/types' import type { RunAndHistoryProps } from './run-and-history' import { useCallback } from 'react' import { useNodes } from 'reactflow' -import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' +import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel' import Divider from '../../base/divider' -import { useNodesInteractions, useNodesReadOnly, useWorkflowRun } from '../hooks' import { useHooksStore } from '../hooks-store' +import { useNodesInteractions } from '../hooks/use-nodes-interactions' +import { useNodesReadOnly } from '../hooks/use-workflow' +import { useWorkflowRun } from '../hooks/use-workflow-run' import { useStore, useWorkflowStore } from '../store' import EditingTitle from './editing-title' import EnvButton from './env-button' diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index 2c6418fcd01..0d699a19a79 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -16,8 +16,9 @@ import { useRestoreWorkflow, } from '@/service/use-workflow' import { FlowType } from '@/types/common' -import { useWorkflowRefreshDraft, useWorkflowRun } from '../hooks' import { useHooksStore } from '../hooks-store' +import { useWorkflowRefreshDraft } from '../hooks/use-workflow-refresh-draft' +import { useWorkflowRun } from '../hooks/use-workflow-run' import { useStore, useWorkflowStore } from '../store' import { WorkflowVersion } from '../types' import RestoringTitle from './restoring-title' diff --git a/web/app/components/workflow/header/header-in-view-history.tsx b/web/app/components/workflow/header/header-in-view-history.tsx index f89ce3ccd2c..d130e91d2ec 100644 --- a/web/app/components/workflow/header/header-in-view-history.tsx +++ b/web/app/components/workflow/header/header-in-view-history.tsx @@ -4,7 +4,7 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' import Divider from '../../base/divider' -import { useWorkflowRun } from '../hooks' +import { useWorkflowRun } from '../hooks/use-workflow-run' import { useWorkflowStore } from '../store' import RunningTitle from './running-title' import ViewHistory from './view-history' diff --git a/web/app/components/workflow/header/index.tsx b/web/app/components/workflow/header/index.tsx index a460821ae71..9e9c08ea3b2 100644 --- a/web/app/components/workflow/header/index.tsx +++ b/web/app/components/workflow/header/index.tsx @@ -2,7 +2,7 @@ import type { HeaderInNormalProps } from './header-in-normal' import type { HeaderInRestoringProps } from './header-in-restoring' import type { HeaderInHistoryProps } from './header-in-view-history' import dynamic from '@/next/dynamic' -import { useWorkflowMode } from '../hooks' +import { useWorkflowMode } from '../hooks/use-workflow-mode' import HeaderInNormal from './header-in-normal' const HeaderInHistory = dynamic(() => import('./header-in-view-history'), { diff --git a/web/app/components/workflow/header/run-and-history.tsx b/web/app/components/workflow/header/run-and-history.tsx index 2c98874e5cd..6f330ef8eca 100644 --- a/web/app/components/workflow/header/run-and-history.tsx +++ b/web/app/components/workflow/header/run-and-history.tsx @@ -2,8 +2,9 @@ import type { ViewHistoryProps } from './view-history' import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useNodesReadOnly, useWorkflowStartRun } from '../hooks' import { useHooksStore } from '../hooks-store' +import { useNodesReadOnly } from '../hooks/use-workflow' +import { useWorkflowStartRun } from '../hooks/use-workflow-start-run' import Checklist from './checklist' import RunMode from './run-mode' import ViewHistory from './view-history' diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx index e3433d68ee9..a3b0d964b89 100644 --- a/web/app/components/workflow/header/run-mode.tsx +++ b/web/app/components/workflow/header/run-mode.tsx @@ -7,18 +7,16 @@ import * as React from 'react' import { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' -import { - useWorkflowRun, - useWorkflowRunValidation, - useWorkflowStartRun, -} from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { ShortcutKbd } from '@/app/components/workflow/shortcuts/shortcut-kbd' import { useStore } from '@/app/components/workflow/store/workflow' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types' import { useEventEmitterContextContext } from '@/context/event-emitter' +import { useWorkflowRunValidation } from '../hooks/use-checklist' import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options' +import { useWorkflowRun } from '../hooks/use-workflow-run' +import { useWorkflowStartRun } from '../hooks/use-workflow-start-run' import { TEST_RUN_MENU_HOTKEY } from '../hotkeys' import TestRunMenu, { TriggerType } from './test-run-menu' diff --git a/web/app/components/workflow/header/running-title.tsx b/web/app/components/workflow/header/running-title.tsx index 63c3279396a..2a663b4f89e 100644 --- a/web/app/components/workflow/header/running-title.tsx +++ b/web/app/components/workflow/header/running-title.tsx @@ -1,7 +1,7 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import { ClockPlay } from '@/app/components/base/icons/src/vender/line/time' -import { useIsChatMode } from '../hooks' +import { useIsChatMode } from '../hooks/use-workflow' import { useStore } from '../store' import { formatWorkflowRunIdentifier } from '../utils' diff --git a/web/app/components/workflow/header/undo-redo.tsx b/web/app/components/workflow/header/undo-redo.tsx index 3aeaef9adf6..90be74c86ef 100644 --- a/web/app/components/workflow/header/undo-redo.tsx +++ b/web/app/components/workflow/header/undo-redo.tsx @@ -3,9 +3,9 @@ import { cn } from '@langgenius/dify-ui/cn' import { memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import { useWorkflowHistoryStore } from '@/app/components/workflow/workflow-history-store' import Divider from '../../base/divider' +import { useNodesReadOnly } from '../hooks/use-workflow' import TipPopup from '../operator/tip-popup' type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void } diff --git a/web/app/components/workflow/header/view-history.tsx b/web/app/components/workflow/header/view-history.tsx index 3b52c3a5e45..98f1ac8dccb 100644 --- a/web/app/components/workflow/header/view-history.tsx +++ b/web/app/components/workflow/header/view-history.tsx @@ -4,16 +4,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' +import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useWorkflowRunHistory } from '@/service/use-workflow' -import { - useIsChatMode, - useNodesInteractions, - useWorkflowInteractions, - useWorkflowRun, -} from '../hooks' +import { useNodesInteractions } from '../hooks/use-nodes-interactions' +import { useIsChatMode } from '../hooks/use-workflow' +import { useWorkflowInteractions } from '../hooks/use-workflow-panel-interactions' +import { useWorkflowRun } from '../hooks/use-workflow-run' import { ControlMode, WorkflowRunningStatus } from '../types' import { formatWorkflowRunIdentifier } from '../utils' diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx index 285866aa842..fbf27def6fd 100644 --- a/web/app/components/workflow/header/view-workflow-history.tsx +++ b/web/app/components/workflow/header/view-workflow-history.tsx @@ -8,8 +8,9 @@ import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '../../base/divider' import { collaborationManager } from '../collaboration/core/collaboration-manager' -import { useNodesReadOnly, useWorkflowHistory } from '../hooks' import { useCollaborativeWorkflow } from '../hooks/use-collaborative-workflow' +import { useNodesReadOnly } from '../hooks/use-workflow' +import { useWorkflowHistory } from '../hooks/use-workflow-history' import TipPopup from '../operator/tip-popup' type ChangeHistoryEntry = { diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts index 9201ec39f5e..c2b6d41b2f6 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-available-var-list.spec.ts @@ -15,16 +15,29 @@ const mockFlowType = vi.hoisted(() => ({ value: undefined as FlowType | undefined, })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => true, - useWorkflow: () => ({ - getTreeLeafNodes: mockGetTreeLeafNodes, - getBeforeNodesInSameBranchIncludeParent: mockGetBeforeNodesInSameBranchIncludeParent, - }), - useWorkflowVariables: () => ({ - getNodeAvailableVars: mockGetNodeAvailableVars, - }), -})) +vi.mock('../use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => true, + useWorkflow: () => ({ + getTreeLeafNodes: mockGetTreeLeafNodes, + getBeforeNodesInSameBranchIncludeParent: mockGetBeforeNodesInSameBranchIncludeParent, + }), + } +}) + +vi.mock('../use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getNodeAvailableVars: mockGetNodeAvailableVars, + }), + } +}) vi.mock('@/app/components/workflow/hooks-store/store', () => ({ useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts deleted file mode 100644 index 8dc5def829e..00000000000 --- a/web/app/components/workflow/hooks/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export * from './use-auto-generate-webhook-url' -export * from './use-available-blocks' -export * from './use-checklist' -export * from './use-DSL' -export * from './use-edges-interactions' -export * from './use-inspect-vars-crud' -export * from './use-node-data-update' -export * from './use-nodes-interactions' -export * from './use-nodes-meta-data' -export * from './use-nodes-sync-draft' -export * from './use-panel-interactions' -export * from './use-selection-interactions' -export * from './use-serial-async-callback' -export * from './use-set-workflow-vars-with-value' -export * from './use-tool-icon' -export * from './use-workflow' -export * from './use-workflow-comment' -export * from './use-workflow-history' -export * from './use-workflow-interactions' -export * from './use-workflow-mode' -export * from './use-workflow-refresh-draft' -export * from './use-workflow-run' -export * from './use-workflow-search' -export * from './use-workflow-start-run' -export * from './use-workflow-variables' diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index c1f4587467e..ea9ebb9d45b 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -44,7 +44,6 @@ import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' import { CUSTOM_NODE } from '../constants' import { useDatasetsDetailStore } from '../datasets-detail-store/store' -import { useGetToolIcon, useNodesMetaData } from '../hooks' import { useHooksStore } from '../hooks-store/store' import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils' import { hasValidInlineAgentBinding, isAgentV2NodeData } from '../nodes/agent-v2/types' @@ -68,6 +67,8 @@ import { getTriggerCheckParams } from '../utils/trigger' import useNodesAvailableVarList, { useGetNodesAvailableVarList, } from './use-nodes-available-var-list' +import { useNodesMetaData } from './use-nodes-meta-data' +import { useGetToolIcon } from './use-tool-icon' export type ChecklistItem = { id: string diff --git a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts index 6a84508e480..e5f30df4438 100644 --- a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts +++ b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts @@ -4,7 +4,6 @@ import type { FlowType } from '@/types/common' import type { NodeWithVar, VarInInspect } from '@/types/workflow' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' -import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { useAllBuiltInTools, @@ -19,6 +18,7 @@ import { import { fetchAllInspectVars } from '@/service/workflow' import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type' import { toNodeOutputVars } from '../nodes/_base/components/variable/utils' +import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync' type Params = { flowType: FlowType diff --git a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts index b3c8a4ea4a0..d8913d5a5fc 100644 --- a/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts +++ b/web/app/components/workflow/hooks/use-inspect-vars-crud-common.ts @@ -5,8 +5,6 @@ import type { VarInInspect } from '@/types/workflow' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' -import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync' -import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' import { isConversationVar, isENV, @@ -23,6 +21,8 @@ import { } from '@/service/use-tools' import { fetchNodeInspectVars } from '@/service/workflow' import { VarInInspectType } from '@/types/workflow' +import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync' +import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync' type Params = { flowId: string diff --git a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts index 38030ec1109..6ea5e52a315 100644 --- a/web/app/components/workflow/hooks/use-nodes-available-var-list.ts +++ b/web/app/components/workflow/hooks/use-nodes-available-var-list.ts @@ -2,7 +2,6 @@ import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/w import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useSnippetDraftStore } from '@/app/components/snippets/draft-store' -import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { appendSnippetInputFieldVars, @@ -11,6 +10,8 @@ import { } from '@/app/components/workflow/nodes/_base/hooks/snippet-input-field-vars' import { BlockEnum } from '@/app/components/workflow/types' import { FlowType } from '@/types/common' +import { useIsChatMode, useWorkflow } from './use-workflow' +import { useWorkflowVariables } from './use-workflow-variables' type Params = { onlyLeafNodeVar?: boolean diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts deleted file mode 100644 index b75771c836a..00000000000 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ /dev/null @@ -1,4 +0,0 @@ -/* oxlint-disable no-barrel-files/no-barrel-files */ -export { useWorkflowOrganize } from './use-workflow-organize' -export { useWorkflowInteractions, useWorkflowMoveMode } from './use-workflow-panel-interactions' -export { useWorkflowUpdate } from './use-workflow-update' diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts index 62c45551d81..e886445230c 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/__tests__/use-workflow-run-event.spec.ts @@ -24,43 +24,79 @@ const handlers = vi.hoisted(() => ({ handleWorkflowNodeHumanInputFormTimeout: vi.fn(), })) -vi.mock('..', () => ({ +vi.mock('../use-workflow-started', () => ({ useWorkflowStarted: () => ({ handleWorkflowStarted: handlers.handleWorkflowStarted }), +})) +vi.mock('../use-workflow-finished', () => ({ useWorkflowFinished: () => ({ handleWorkflowFinished: handlers.handleWorkflowFinished }), +})) +vi.mock('../use-workflow-failed', () => ({ useWorkflowFailed: () => ({ handleWorkflowFailed: handlers.handleWorkflowFailed }), +})) +vi.mock('../use-workflow-node-started', () => ({ useWorkflowNodeStarted: () => ({ handleWorkflowNodeStarted: handlers.handleWorkflowNodeStarted }), +})) +vi.mock('../use-workflow-node-finished', () => ({ useWorkflowNodeFinished: () => ({ handleWorkflowNodeFinished: handlers.handleWorkflowNodeFinished, }), +})) +vi.mock('../use-workflow-node-iteration-started', () => ({ useWorkflowNodeIterationStarted: () => ({ handleWorkflowNodeIterationStarted: handlers.handleWorkflowNodeIterationStarted, }), +})) +vi.mock('../use-workflow-node-iteration-next', () => ({ useWorkflowNodeIterationNext: () => ({ handleWorkflowNodeIterationNext: handlers.handleWorkflowNodeIterationNext, }), +})) +vi.mock('../use-workflow-node-iteration-finished', () => ({ useWorkflowNodeIterationFinished: () => ({ handleWorkflowNodeIterationFinished: handlers.handleWorkflowNodeIterationFinished, }), +})) +vi.mock('../use-workflow-node-loop-started', () => ({ useWorkflowNodeLoopStarted: () => ({ handleWorkflowNodeLoopStarted: handlers.handleWorkflowNodeLoopStarted, }), +})) +vi.mock('../use-workflow-node-loop-next', () => ({ useWorkflowNodeLoopNext: () => ({ handleWorkflowNodeLoopNext: handlers.handleWorkflowNodeLoopNext, }), +})) +vi.mock('../use-workflow-node-loop-finished', () => ({ useWorkflowNodeLoopFinished: () => ({ handleWorkflowNodeLoopFinished: handlers.handleWorkflowNodeLoopFinished, }), +})) +vi.mock('../use-workflow-node-retry', () => ({ useWorkflowNodeRetry: () => ({ handleWorkflowNodeRetry: handlers.handleWorkflowNodeRetry }), +})) +vi.mock('../use-workflow-text-chunk', () => ({ useWorkflowTextChunk: () => ({ handleWorkflowTextChunk: handlers.handleWorkflowTextChunk }), +})) +vi.mock('../use-workflow-text-replace', () => ({ useWorkflowTextReplace: () => ({ handleWorkflowTextReplace: handlers.handleWorkflowTextReplace }), +})) +vi.mock('../use-workflow-agent-log', () => ({ useWorkflowAgentLog: () => ({ handleWorkflowAgentLog: handlers.handleWorkflowAgentLog }), +})) +vi.mock('../use-workflow-paused', () => ({ useWorkflowPaused: () => ({ handleWorkflowPaused: handlers.handleWorkflowPaused }), +})) +vi.mock('../use-workflow-node-human-input-required', () => ({ useWorkflowNodeHumanInputRequired: () => ({ handleWorkflowNodeHumanInputRequired: handlers.handleWorkflowNodeHumanInputRequired, }), +})) +vi.mock('../use-workflow-node-human-input-form-filled', () => ({ useWorkflowNodeHumanInputFormFilled: () => ({ handleWorkflowNodeHumanInputFormFilled: handlers.handleWorkflowNodeHumanInputFormFilled, }), +})) +vi.mock('../use-workflow-node-human-input-form-timeout', () => ({ useWorkflowNodeHumanInputFormTimeout: () => ({ handleWorkflowNodeHumanInputFormTimeout: handlers.handleWorkflowNodeHumanInputFormTimeout, }), diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts b/web/app/components/workflow/hooks/use-workflow-run-event/index.ts deleted file mode 100644 index 43486873339..00000000000 --- a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export * from './use-workflow-agent-log' -export * from './use-workflow-failed' -export * from './use-workflow-finished' -export * from './use-workflow-node-finished' -export * from './use-workflow-node-human-input-form-filled' -export * from './use-workflow-node-human-input-form-timeout' -export * from './use-workflow-node-human-input-required' -export * from './use-workflow-node-iteration-finished' -export * from './use-workflow-node-iteration-next' -export * from './use-workflow-node-iteration-started' -export * from './use-workflow-node-loop-finished' -export * from './use-workflow-node-loop-next' -export * from './use-workflow-node-loop-started' -export * from './use-workflow-node-retry' -export * from './use-workflow-node-started' -export * from './use-workflow-paused' -export * from './use-workflow-started' -export * from './use-workflow-text-chunk' -export * from './use-workflow-text-replace' diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts index 2366fdd9684..ff3f5cb1965 100644 --- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts +++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts @@ -1,25 +1,23 @@ -import { - useWorkflowAgentLog, - useWorkflowFailed, - useWorkflowFinished, - useWorkflowNodeFinished, - useWorkflowNodeHumanInputFormFilled, - useWorkflowNodeHumanInputFormTimeout, - useWorkflowNodeHumanInputRequired, - useWorkflowNodeIterationFinished, - useWorkflowNodeIterationNext, - useWorkflowNodeIterationStarted, - useWorkflowNodeLoopFinished, - useWorkflowNodeLoopNext, - useWorkflowNodeLoopStarted, - useWorkflowNodeRetry, - useWorkflowNodeStarted, - useWorkflowPaused, - useWorkflowStarted, - useWorkflowTextChunk, - useWorkflowTextReplace, -} from '.' +import { useWorkflowAgentLog } from './use-workflow-agent-log' +import { useWorkflowFailed } from './use-workflow-failed' +import { useWorkflowFinished } from './use-workflow-finished' +import { useWorkflowNodeFinished } from './use-workflow-node-finished' +import { useWorkflowNodeHumanInputFormFilled } from './use-workflow-node-human-input-form-filled' +import { useWorkflowNodeHumanInputFormTimeout } from './use-workflow-node-human-input-form-timeout' +import { useWorkflowNodeHumanInputRequired } from './use-workflow-node-human-input-required' +import { useWorkflowNodeIterationFinished } from './use-workflow-node-iteration-finished' +import { useWorkflowNodeIterationNext } from './use-workflow-node-iteration-next' +import { useWorkflowNodeIterationStarted } from './use-workflow-node-iteration-started' +import { useWorkflowNodeLoopFinished } from './use-workflow-node-loop-finished' +import { useWorkflowNodeLoopNext } from './use-workflow-node-loop-next' +import { useWorkflowNodeLoopStarted } from './use-workflow-node-loop-started' +import { useWorkflowNodeRetry } from './use-workflow-node-retry' +import { useWorkflowNodeStarted } from './use-workflow-node-started' +import { useWorkflowPaused } from './use-workflow-paused' import { useWorkflowReasoning } from './use-workflow-reasoning' +import { useWorkflowStarted } from './use-workflow-started' +import { useWorkflowTextChunk } from './use-workflow-text-chunk' +import { useWorkflowTextReplace } from './use-workflow-text-replace' export const useWorkflowRunEvent = () => { const { handleWorkflowStarted } = useWorkflowStarted() diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index e957ed467f5..dd4127f8472 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -6,11 +6,9 @@ import { uniqBy } from 'es-toolkit/compat' import { useCallback } from 'react' import { getIncomers, getOutgoers } from 'reactflow' import { useStore as useAppStore } from '@/app/components/app/store' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants' import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants' import { AppModeEnum } from '@/types/app' -import { useNodesMetaData } from '.' import { SUPPORT_OUTPUT_VARS_NODE } from '../constants' import { useHooksStore } from '../hooks-store' import { @@ -24,6 +22,8 @@ import { WorkflowRunningStatus } from '../types' import { getNodeCatalogType } from '../utils' import { getWorkflowEntryNode, isWorkflowEntryNode } from '../utils/workflow-entry' import { useAvailableBlocks } from './use-available-blocks' +import { useCollaborativeWorkflow } from './use-collaborative-workflow' +import { useNodesMetaData } from './use-nodes-meta-data' export const useIsChatMode = () => { const appDetail = useAppStore((s) => s.appDetail) diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index aae1f8bf018..30fb9578b13 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -67,21 +67,17 @@ import CustomConnectionLine from './custom-connection-line' import CustomEdge from './custom-edge' import DatasetsDetailProvider from './datasets-detail-store/provider' import HelpLine from './help-line' -import { - useEdgesInteractions, - useNodesInteractions, - useNodesReadOnly, - useNodesSyncDraft, - usePanelInteractions, - useSelectionInteractions, - useSetWorkflowVarsWithValue, - useWorkflow, - useWorkflowReadOnly, - useWorkflowRefreshDraft, -} from './hooks' import { HooksStoreContextProvider, useHooksStore } from './hooks-store' +import { useEdgesInteractions } from './hooks/use-edges-interactions' import { useLocateNode } from './hooks/use-locate-node' +import { useNodesInteractions } from './hooks/use-nodes-interactions' +import { useNodesSyncDraft } from './hooks/use-nodes-sync-draft' +import { usePanelInteractions } from './hooks/use-panel-interactions' +import { useSelectionInteractions } from './hooks/use-selection-interactions' +import { useSetWorkflowVarsWithValue } from './hooks/use-set-workflow-vars-with-value' +import { useNodesReadOnly, useWorkflow, useWorkflowReadOnly } from './hooks/use-workflow' import { useWorkflowComment } from './hooks/use-workflow-comment' +import { useWorkflowRefreshDraft } from './hooks/use-workflow-refresh-draft' import { useWorkflowSearch } from './hooks/use-workflow-search' import { shouldPreventWorkflowBrowserDefault } from './hotkeys' import CustomNode from './nodes' diff --git a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx index 4ffb14f12ee..1bedad990da 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/details.spec.tsx @@ -7,18 +7,15 @@ import { import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - useAvailableBlocks, - useIsChatMode, - useNodeMetaData, - useNodesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { useAllWorkflowTools } from '@/service/use-tools' import { FlowType } from '@/types/common' +import { useAvailableBlocks } from '../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../hooks/use-nodes-interactions' +import { useNodeMetaData } from '../../hooks/use-nodes-meta-data' +import { useIsChatMode, useNodesReadOnly } from '../../hooks/use-workflow' import { ChangeBlockMenuTrigger } from '../change-block-menu-trigger' import { NodeActionsDropdownContent } from '../dropdown-content' @@ -48,14 +45,39 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ ), })) -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAvailableBlocks: vi.fn(), - useIsChatMode: vi.fn(), - useNodeMetaData: vi.fn(), + } +}) + +vi.mock('../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, useNodesInteractions: vi.fn(), + } +}) + +vi.mock('../../hooks/use-nodes-meta-data', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeMetaData: vi.fn(), + } +}) + +vi.mock('../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: vi.fn(), useNodesReadOnly: vi.fn(), } }) diff --git a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx index 99927fa9a73..d5a595881ff 100644 --- a/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/node-actions-menu/__tests__/index.spec.tsx @@ -3,21 +3,36 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - useNodeMetaData, - useNodesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' import { useAllWorkflowTools } from '@/service/use-tools' +import { useNodesInteractions } from '../../hooks/use-nodes-interactions' +import { useNodeMetaData } from '../../hooks/use-nodes-meta-data' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { NodeActionsDropdown } from '../index' -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesInteractions: vi.fn(), + } +}) + +vi.mock('../../hooks/use-nodes-meta-data', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useNodeMetaData: vi.fn(), - useNodesInteractions: vi.fn(), + } +}) + +vi.mock('../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, useNodesReadOnly: vi.fn(), } }) diff --git a/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx index a9e9ba82faf..51f60f4bbff 100644 --- a/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx +++ b/web/app/components/workflow/node-actions-menu/change-block-menu-trigger.tsx @@ -3,16 +3,14 @@ import { intersection } from 'es-toolkit/array' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' -import { - useAvailableBlocks, - useIsChatMode, - useNodesInteractions, -} from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' import { getNodeCatalogType } from '@/app/components/workflow/utils' import { FlowType } from '@/types/common' +import { useAvailableBlocks } from '../hooks/use-available-blocks' +import { useNodesInteractions } from '../hooks/use-nodes-interactions' +import { useIsChatMode } from '../hooks/use-workflow' type ChangeBlockMenuTriggerProps = { nodeId: string diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts index affd9e63460..a0c46f95440 100644 --- a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -2,17 +2,15 @@ import type { Node } from '@/app/components/workflow/types' import { useCallback, useMemo } from 'react' import { useEdges } from 'reactflow' import { CollectionType } from '@/app/components/tools/types' -import { - useNodeMetaData, - useNodesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { canRunBySingle } from '@/app/components/workflow/utils' import { useAllWorkflowTools } from '@/service/use-tools' import { canFindTool } from '@/utils' +import { useNodesInteractions } from '../hooks/use-nodes-interactions' +import { useNodeMetaData } from '../hooks/use-nodes-meta-data' +import { useNodesReadOnly } from '../hooks/use-workflow' type UseNodeActionsMenuModelParams = { id: string diff --git a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx index 5d963a9f550..5c495c7fa56 100644 --- a/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/_base/__tests__/node.spec.tsx @@ -26,22 +26,35 @@ vi.mock('@/context/account-state', async () => { return createAccountStateModuleMock(() => mockConsoleState) }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: false }), - useToolIcon: () => undefined, -})) +vi.mock('../../../hooks/use-tool-icon', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useToolIcon: () => undefined, + } +}) + +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: false }), + } +}) vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => ({ useCollaboration: (...args: unknown[]) => mockUseCollaboration(...args), })) -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../../hooks/use-inspect-vars-crud', () => ({ default: () => ({ hasNodeInspectVars: mockHasNodeInspectVars, }), })) -vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ +vi.mock('../../../hooks/use-node-plugin-installation', () => ({ useNodePluginInstallation: (...args: unknown[]) => mockUseNodePluginInstallation(...args), })) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx index 68484a67b1e..164e7e2489d 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -34,17 +34,30 @@ vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => mockTriggerDynamicOptionsState, })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => false, - useWorkflow: () => ({ - getTreeLeafNodes: () => [], - getNodeById: () => undefined, - getBeforeNodesInSameBranchIncludeParent: () => [], - }), - useWorkflowVariables: () => ({ - getNodeAvailableVars: () => [], - }), -})) +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => false, + useWorkflow: () => ({ + getTreeLeafNodes: () => [], + getNodeById: () => undefined, + getBeforeNodesInSameBranchIncludeParent: () => [], + }), + } +}) + +vi.mock('../../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getNodeAvailableVars: () => [], + }), + } +}) vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({ AppSelector: ({ onSelect }: { onSelect: (value: AppSelectorValue) => void }) => ( diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx index 73b2b888673..167d396ef25 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.spec.tsx @@ -9,17 +9,30 @@ import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__ import { VarKindType } from '../../types' import FormInputItem from '../form-input-item' -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => false, - useWorkflow: () => ({ - getTreeLeafNodes: () => [], - getNodeById: () => undefined, - getBeforeNodesInSameBranchIncludeParent: () => [], - }), - useWorkflowVariables: () => ({ - getNodeAvailableVars: () => [], - }), -})) +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => false, + useWorkflow: () => ({ + getTreeLeafNodes: () => [], + getNodeById: () => undefined, + getBeforeNodesInSameBranchIncludeParent: () => [], + }), + } +}) + +vi.mock('../../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getNodeAvailableVars: () => [], + }), + } +}) const createSchema = ( overrides: Partial< diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx index fc6494a3fb5..478ffc576dc 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-control.spec.tsx @@ -14,13 +14,20 @@ const { mockHandleNodeSelect, mockCanRunBySingle, mockUseNodesReadOnly } = vi.ho let mockPluginInstallLocked = false -vi.mock('../../../../hooks', async () => { - const actual = await vi.importActual('../../../../hooks') +vi.mock('../../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect, }), + } +}) + +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, useNodesReadOnly: mockUseNodesReadOnly, } }) diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx index e7e0e08d461..17032d49034 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/node-handle.spec.tsx @@ -104,19 +104,40 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ ), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useAvailableBlocks: () => ({ - availablePrevBlocks: mockHooksState.availablePrevBlocks, - availableNextBlocks: mockHooksState.availableNextBlocks, - }), - useIsChatMode: () => mockHooksState.isChatMode, - useNodesInteractions: () => ({ - handleNodeAdd: mockHandleNodeAdd, - }), - useNodesReadOnly: () => ({ - getNodesReadOnly: () => mockHooksState.isReadOnly, - }), -})) +vi.mock('../../../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useAvailableBlocks: () => ({ + availablePrevBlocks: mockHooksState.availablePrevBlocks, + availableNextBlocks: mockHooksState.availableNextBlocks, + }), + } +}) + +vi.mock('../../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesInteractions: () => ({ + handleNodeAdd: mockHandleNodeAdd, + }), + } +}) + +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => mockHooksState.isChatMode, + useNodesReadOnly: () => ({ + getNodesReadOnly: () => mockHooksState.isReadOnly, + }), + } +}) vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: MockStoreState) => T) => selector(mockStoreState), diff --git a/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx b/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx index 14804583920..6db6d22be37 100644 --- a/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx +++ b/web/app/components/workflow/nodes/_base/components/add-variable-popup-with-position.tsx @@ -1,7 +1,9 @@ import type { ValueSelector, Var, VarType } from '../../../types' import { useClickAway } from 'ahooks' import { memo, useCallback, useMemo, useRef } from 'react' -import { useIsChatMode, useNodeDataUpdate, useWorkflow, useWorkflowVariables } from '../../../hooks' +import { useNodeDataUpdate } from '../../../hooks/use-node-data-update' +import { useIsChatMode, useWorkflow } from '../../../hooks/use-workflow' +import { useWorkflowVariables } from '../../../hooks/use-workflow-variables' import { useStore } from '../../../store' import { useVariableAssigner } from '../../variable-assigner/hooks' import { filterVar } from '../../variable-assigner/utils' diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts b/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts index 07186480b02..84424bade49 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts +++ b/web/app/components/workflow/nodes/_base/components/error-handle/hooks.ts @@ -1,7 +1,8 @@ import type { DefaultValueForm } from './types' import type { CommonNodeType } from '@/app/components/workflow/types' import { useCallback, useMemo, useState } from 'react' -import { useEdgesInteractions, useNodeDataUpdate } from '@/app/components/workflow/hooks' +import { useEdgesInteractions } from '../../../../hooks/use-edges-interactions' +import { useNodeDataUpdate } from '../../../../hooks/use-node-data-update' import { ErrorHandleTypeEnum } from './types' import { getDefaultValue } from './utils' diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx index 6e9317bcd49..773568142c2 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/index.spec.tsx @@ -3,14 +3,12 @@ import type { Edge, Node } from '@/app/components/workflow/types' import { screen } from '@testing-library/react' import { createEdge, createNode } from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - useAvailableBlocks, - useNodesInteractions, - useNodesReadOnly, - useToolIcon, -} from '@/app/components/workflow/hooks' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableBlocks } from '../../../../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../../../../hooks/use-nodes-interactions' +import { useToolIcon } from '../../../../../hooks/use-tool-icon' +import { useNodesReadOnly } from '../../../../../hooks/use-workflow' import NextStep from '../index' vi.mock('@/app/components/workflow/block-selector', () => ({ @@ -23,17 +21,43 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ }, })) -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAvailableBlocks: vi.fn(), + } +}) + +vi.mock('../../../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, useNodesInteractions: vi.fn(), - useNodesReadOnly: vi.fn(), + } +}) + +vi.mock('../../../../../hooks/use-tool-icon', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, useToolIcon: vi.fn(), } }) +vi.mock('../../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + } +}) + const mockUseAvailableBlocks = vi.mocked(useAvailableBlocks) const mockUseNodesInteractions = vi.mocked(useNodesInteractions) const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx index 0846cc69b61..bfec891cd70 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/__tests__/operator.spec.tsx @@ -3,8 +3,9 @@ import type { CommonNodeType } from '@/app/components/workflow/types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' -import { useAvailableBlocks, useNodesInteractions } from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableBlocks } from '../../../../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../../../../hooks/use-nodes-interactions' import Operator from '../operator' vi.mock('@langgenius/dify-ui/dropdown-menu', async () => { @@ -96,11 +97,21 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ ), })) -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAvailableBlocks: vi.fn(), + } +}) + +vi.mock('../../../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, useNodesInteractions: vi.fn(), } }) diff --git a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx index 568b3c8ec1a..e478f8ecd46 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/add.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/add.tsx @@ -4,12 +4,10 @@ import { RiAddLine } from '@remixicon/react' import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' -import { - useAvailableBlocks, - useNodesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' import { getNodeCatalogType } from '@/app/components/workflow/utils' +import { useAvailableBlocks } from '../../../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../../../hooks/use-nodes-interactions' +import { useNodesReadOnly } from '../../../../hooks/use-workflow' type AddProps = { nodeId: string diff --git a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx index 6d9424e1b36..6b3d7497728 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/index.tsx @@ -6,7 +6,7 @@ import { getConnectedEdges, getOutgoers, useStore } from 'reactflow' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import { hasErrorHandleNode } from '@/app/components/workflow/utils' import BlockIcon from '../../../../block-icon' -import { useToolIcon } from '../../../../hooks' +import { useToolIcon } from '../../../../hooks/use-tool-icon' import { BlockEnum } from '../../../../types' import Container from './container' import Line from './line' diff --git a/web/app/components/workflow/nodes/_base/components/next-step/item.tsx b/web/app/components/workflow/nodes/_base/components/next-step/item.tsx index 49de337b509..dd2a9739edb 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/item.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/item.tsx @@ -4,11 +4,9 @@ import { cn } from '@langgenius/dify-ui/cn' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import BlockIcon from '@/app/components/workflow/block-icon' -import { - useNodesInteractions, - useNodesReadOnly, - useToolIcon, -} from '@/app/components/workflow/hooks' +import { useNodesInteractions } from '../../../../hooks/use-nodes-interactions' +import { useToolIcon } from '../../../../hooks/use-tool-icon' +import { useNodesReadOnly } from '../../../../hooks/use-workflow' import Operator from './operator' type ItemProps = { diff --git a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx index 1683e4258d6..2d3bdfc939f 100644 --- a/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx +++ b/web/app/components/workflow/nodes/_base/components/next-step/operator.tsx @@ -9,8 +9,9 @@ import { intersection } from 'es-toolkit/array' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' -import { useAvailableBlocks, useNodesInteractions } from '@/app/components/workflow/hooks' import { getNodeCatalogType } from '@/app/components/workflow/utils' +import { useAvailableBlocks } from '../../../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../../../hooks/use-nodes-interactions' type ChangeItemProps = { data: CommonNodeType diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 7e530f3ffaf..13699a140c5 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -8,7 +8,8 @@ import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevice import { useHooksStore } from '@/app/components/workflow/hooks-store' import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu' import { useWorkflowStore } from '@/app/components/workflow/store' -import { useNodesInteractions, useNodesReadOnly } from '../../../hooks' +import { useNodesInteractions } from '../../../hooks/use-nodes-interactions' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import { NodeRunningStatus } from '../../../types' import { canRunBySingle } from '../../../utils' diff --git a/web/app/components/workflow/nodes/_base/components/node-handle.tsx b/web/app/components/workflow/nodes/_base/components/node-handle.tsx index 549676132c0..324ea9d7b0c 100644 --- a/web/app/components/workflow/nodes/_base/components/node-handle.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-handle.tsx @@ -6,12 +6,9 @@ import { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Handle, Position } from 'reactflow' import BlockSelector from '../../../block-selector' -import { - useAvailableBlocks, - useIsChatMode, - useNodesInteractions, - useNodesReadOnly, -} from '../../../hooks' +import { useAvailableBlocks } from '../../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../../hooks/use-nodes-interactions' +import { useIsChatMode, useNodesReadOnly } from '../../../hooks/use-workflow' import { useStore, useWorkflowStore } from '../../../store' import { BlockEnum, NodeRunningStatus } from '../../../types' import { getNodeCatalogType } from '../../../utils' diff --git a/web/app/components/workflow/nodes/_base/components/node-resizer.tsx b/web/app/components/workflow/nodes/_base/components/node-resizer.tsx index ce1df3d2e34..eb7b00520e0 100644 --- a/web/app/components/workflow/nodes/_base/components/node-resizer.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-resizer.tsx @@ -3,7 +3,7 @@ import type { CommonNodeType } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' import { memo, useCallback } from 'react' import { NodeResizeControl } from 'reactflow' -import { useNodesInteractions } from '../../../hooks' +import { useNodesInteractions } from '../../../hooks/use-nodes-interactions' const Icon = () => { return ( diff --git a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx index a853be7373f..08c8dd127b9 100644 --- a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx +++ b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx @@ -17,12 +17,12 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop import { Jinja } from '@/app/components/base/icons/src/vender/workflow' import PromptEditor from '@/app/components/base/prompt-editor' import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block' -import { useWorkflowVariableType } from '@/app/components/workflow/hooks' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars' import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/toggle-expand-btn' import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend' import { useStore } from '@/app/components/workflow/store' import { useEventEmitterContextContext } from '@/context/event-emitter' +import { useWorkflowVariableType } from '../../../../hooks/use-workflow-variables' import { BlockEnum, EditionType } from '../../../../types' import { CodeLanguage } from '../../../code/types' import PromptGeneratorBtn from '../../../llm/components/prompt-generator-btn' diff --git a/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx b/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx index 6b72fd99141..e0827e93085 100644 --- a/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx +++ b/web/app/components/workflow/nodes/_base/components/readonly-input-with-select-var.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { VariableLabelInText } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' -import { useWorkflow } from '../../../hooks' +import { useWorkflow } from '../../../hooks/use-workflow' import { BlockEnum } from '../../../types' import { getNodeInfoById, isSystemVar } from './variable/utils' diff --git a/web/app/components/workflow/nodes/_base/components/retry/hooks.ts b/web/app/components/workflow/nodes/_base/components/retry/hooks.ts index 17dba94a249..25b2942801c 100644 --- a/web/app/components/workflow/nodes/_base/components/retry/hooks.ts +++ b/web/app/components/workflow/nodes/_base/components/retry/hooks.ts @@ -1,6 +1,6 @@ import type { WorkflowRetryConfig } from './types' import { useCallback } from 'react' -import { useNodeDataUpdate } from '@/app/components/workflow/hooks' +import { useNodeDataUpdate } from '../../../../hooks/use-node-data-update' export const useRetryConfig = (id: string) => { const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx index 3bc81767863..875763a3d1c 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.branches.spec.tsx @@ -22,18 +22,32 @@ vi.mock('@/service/use-plugins', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => false, - useWorkflow: () => ({ - getTreeLeafNodes: () => [], - getNodeById: () => undefined, - getBeforeNodesInSameBranchIncludeParent: () => [], - }), - useWorkflowVariables: () => ({ - getNodeAvailableVars: () => [], - getCurrentVariableType: () => undefined, - }), -})) +vi.mock('../../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => false, + useWorkflow: () => ({ + getTreeLeafNodes: () => [], + getNodeById: () => undefined, + getBeforeNodesInSameBranchIncludeParent: () => [], + }), + } +}) + +vi.mock('../../../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getNodeAvailableVars: () => [], + getCurrentVariableType: () => undefined, + }), + } +}) vi.mock('../var-reference-popup', () => ({ default: ({ diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx index 2a76386b33e..8f4248fc90e 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.spec.tsx @@ -10,18 +10,32 @@ import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__ import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' import VarReferencePicker from '../var-reference-picker' -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => false, - useWorkflow: () => ({ - getTreeLeafNodes: () => [], - getNodeById: () => undefined, - getBeforeNodesInSameBranchIncludeParent: () => [], - }), - useWorkflowVariables: () => ({ - getNodeAvailableVars: () => [], - getCurrentVariableType: () => undefined, - }), -})) +vi.mock('../../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => false, + useWorkflow: () => ({ + getTreeLeafNodes: () => [], + getNodeById: () => undefined, + getBeforeNodesInSameBranchIncludeParent: () => [], + }), + } +}) + +vi.mock('../../../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getNodeAvailableVars: () => [], + getCurrentVariableType: () => undefined, + }), + } +}) describe('VarReferencePicker', () => { const startNode = createStartNode({ diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index ae932f7f2f5..dc778b85427 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -25,13 +25,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNodes, useReactFlow, useStoreApi } from 'reactflow' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useIsChatMode, useWorkflowVariables } from '@/app/components/workflow/hooks' // import type { BaseResource, BaseResourceProvider } from '@/app/components/workflow/nodes/_base/types' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { isExceptionVariable } from '@/app/components/workflow/utils' import { useFetchDynamicOptions } from '@/service/use-plugins' +import { useIsChatMode } from '../../../../hooks/use-workflow' +import { useWorkflowVariables } from '../../../../hooks/use-workflow-variables' import useAvailableVarList from '../../hooks/use-available-var-list' import { removeFileVars, varTypeToStructType } from './utils' import VarFullPathPanel from './var-full-path-panel' diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index 7aebb57ca17..dc530886182 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -104,36 +104,99 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/store', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useAvailableBlocks: () => ({ availableNextBlocks: [] }), - useEdgesInteractions: () => ({ - handleEdgeDeleteByDeleteBranch: vi.fn(), - }), - useNodeDataUpdate: () => ({ - handleNodeDataUpdate: mockHandleNodeDataUpdate, - handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, - }), - useNodesInteractions: () => ({ - handleNodeSelect: mockHandleNodeSelect, - }), - useNodesMetaData: () => ({ - nodesMap: { - [BlockEnum.Tool]: { defaultRunInputData: {}, metaData: { helpLinkUri: '' } }, - [BlockEnum.DataSource]: { defaultRunInputData: {}, metaData: { helpLinkUri: '' } }, +vi.mock('../../../../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useAvailableBlocks: () => ({ availableNextBlocks: [] }), + } +}) + +vi.mock('../../../../../hooks/use-edges-interactions', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + useEdgesInteractions: () => ({ + handleEdgeDeleteByDeleteBranch: vi.fn(), + }), + } +}) + +vi.mock('../../../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: () => ({ + handleNodeDataUpdate: mockHandleNodeDataUpdate, + handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, + }), + } +}) + +vi.mock('../../../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), + } +}) + +vi.mock('../../../../../hooks/use-nodes-meta-data', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesMetaData: () => ({ + nodesMap: { + [BlockEnum.Tool]: { defaultRunInputData: {}, metaData: { helpLinkUri: '' } }, + [BlockEnum.DataSource]: { defaultRunInputData: {}, metaData: { helpLinkUri: '' } }, + }, + }), + } +}) + +vi.mock('../../../../../hooks/use-tool-icon', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useToolIcon: () => undefined, + } +}) + +vi.mock('../../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ + nodesReadOnly: mockNodesReadOnly, + }), + } +}) + +vi.mock('../../../../../hooks/use-workflow-history', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowHistory: () => ({ + saveStateToHistory: mockSaveStateToHistory, + }), + WorkflowHistoryEvent: { + NodeTitleChange: 'NodeTitleChange', + NodeDescriptionChange: 'NodeDescriptionChange', }, - }), - useNodesReadOnly: () => ({ - nodesReadOnly: mockNodesReadOnly, - }), - useToolIcon: () => undefined, - useWorkflowHistory: () => ({ - saveStateToHistory: mockSaveStateToHistory, - }), - WorkflowHistoryEvent: { - NodeTitleChange: 'NodeTitleChange', - NodeDescriptionChange: 'NodeDescriptionChange', - }, -})) + } +}) vi.mock('@/app/components/workflow/hooks-store', () => ({ useHooksStore: ( @@ -153,7 +216,7 @@ vi.mock('@/app/components/workflow/hooks-store', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../../../../hooks/use-inspect-vars-crud', () => ({ default: () => ({ appendNodeInspectVars: vi.fn(), }), diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 8a714bb1b4e..0cab2f85aee 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -29,18 +29,7 @@ import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' import BlockIcon from '@/app/components/workflow/block-icon' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration' -import { - useAvailableBlocks, - useNodeDataUpdate, - useNodesInteractions, - useNodesMetaData, - useNodesReadOnly, - useToolIcon, - useWorkflowHistory, - WorkflowHistoryEvent, -} from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import { NodeActionsDropdown } from '@/app/components/workflow/node-actions-menu' import Split from '@/app/components/workflow/nodes/_base/components/split' import { useSetWorkflowNodePanelWidth } from '@/app/components/workflow/persistence/local-storage-options' @@ -59,6 +48,14 @@ import { userProfileAtom } from '@/context/account-state' import { useAllBuiltInTools } from '@/service/use-tools' import { useAllTriggerPlugins } from '@/service/use-triggers' import { FlowType } from '@/types/common' +import { useAvailableBlocks } from '../../../../hooks/use-available-blocks' +import useInspectVarsCrud from '../../../../hooks/use-inspect-vars-crud' +import { useNodeDataUpdate } from '../../../../hooks/use-node-data-update' +import { useNodesInteractions } from '../../../../hooks/use-nodes-interactions' +import { useNodesMetaData } from '../../../../hooks/use-nodes-meta-data' +import { useToolIcon } from '../../../../hooks/use-tool-icon' +import { useNodesReadOnly } from '../../../../hooks/use-workflow' +import { useWorkflowHistory, WorkflowHistoryEvent } from '../../../../hooks/use-workflow-history' import { useResizePanel } from '../../hooks/use-resize-panel' import BeforeRunForm from '../before-run-form' import PanelWrap from '../before-run-form/panel-wrap' diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts index 8f3c660211a..03e5445de37 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/__tests__/use-last-run.spec.ts @@ -8,19 +8,25 @@ const mockHandleSyncWorkflowDraft = vi.fn() const mockShowSingleRun = vi.fn() const mockHandleRun = vi.fn() -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesSyncDraft: () => ({ - handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, - }), -})) +vi.mock('../../../../../../hooks/use-nodes-sync-draft', async (importOriginal) => { + const actual = + await importOriginal() -vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({ + return { + ...actual, + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }), + } +}) + +vi.mock('../../../../../../hooks/use-checklist', () => ({ useWorkflowRunValidation: () => ({ warningNodes: [], }), })) -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../../../../../hooks/use-inspect-vars-crud', () => ({ default: () => ({ conversationVars: [], systemVars: [], diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index 8840435f021..df98ce1cee7 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -4,9 +4,6 @@ import type { Params as OneStepRunParams } from '@/app/components/workflow/nodes import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types' import { toast } from '@langgenius/dify-ui/toast' import { useCallback, useEffect, useState } from 'react' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks' -import { useWorkflowRunValidation } from '@/app/components/workflow/hooks/use-checklist' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import useOneStepRun from '@/app/components/workflow/nodes/_base/hooks/use-one-step-run' import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/nodes/assigner/use-single-run-form-params' import useCodeSingleRunFormParams from '@/app/components/workflow/nodes/code/use-single-run-form-params' @@ -32,6 +29,9 @@ import { BlockEnum } from '@/app/components/workflow/types' import { isSupportCustomRunForm } from '@/app/components/workflow/utils' import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' import { useInvalidLastRun } from '@/service/use-workflow' +import { useWorkflowRunValidation } from '../../../../../hooks/use-checklist' +import useInspectVarsCrud from '../../../../../hooks/use-inspect-vars-crud' +import { useNodesSyncDraft } from '../../../../../hooks/use-nodes-sync-draft' import { TabType } from '../types' const singleRunFormParamsHooks: Record = { diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts index 015d8e02ddc..547f84779a7 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-available-var-list.spec.ts @@ -17,17 +17,30 @@ vi.mock('@/app/components/snippets/draft-store', () => ({ selector({ inputFields: [] }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => true, - useWorkflow: () => ({ - getTreeLeafNodes: mockGetTreeLeafNodes, - getBeforeNodesInSameBranchIncludeParent: mockGetBeforeNodesInSameBranchIncludeParent, - getNodeById: mockGetNodeById, - }), - useWorkflowVariables: () => ({ - getNodeAvailableVars: mockGetNodeAvailableVars, - }), -})) +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => true, + useWorkflow: () => ({ + getTreeLeafNodes: mockGetTreeLeafNodes, + getBeforeNodesInSameBranchIncludeParent: mockGetBeforeNodesInSameBranchIncludeParent, + getNodeById: mockGetNodeById, + }), + } +}) + +vi.mock('../../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getNodeAvailableVars: mockGetNodeAvailableVars, + }), + } +}) vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: { ragPipelineVariables: unknown[] }) => unknown) => diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts index ca8558df528..e2ae05ca07f 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-node-crud.spec.ts @@ -7,11 +7,16 @@ const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => ({ current: vi.fn(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodeDataUpdate: () => ({ - handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft.current, - }), -})) +vi.mock('../../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: () => ({ + handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft.current, + }), + } +}) type TestNodeData = CommonNodeType<{ value: string diff --git a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts index 7d6c37bae88..43d36571549 100644 --- a/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts +++ b/web/app/components/workflow/nodes/_base/hooks/__tests__/use-one-step-run.spec.ts @@ -26,36 +26,49 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => false, - useNodeDataUpdate: () => ({ - handleNodeDataUpdate: vi.fn(), - }), - useWorkflow: () => ({ - getBeforeNodesInSameBranch: () => [ - { - id: 'start', - data: { - type: 'start', - title: 'Start', - variables: [], - }, - }, - ], - getBeforeNodesInSameBranchIncludeParent: () => [ - { - id: 'start', - data: { - type: 'start', - title: 'Start', - variables: [], - }, - }, - ], - }), -})) +vi.mock('../../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ + return { + ...actual, + useNodeDataUpdate: () => ({ + handleNodeDataUpdate: vi.fn(), + }), + } +}) + +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => false, + useWorkflow: () => ({ + getBeforeNodesInSameBranch: () => [ + { + id: 'start', + data: { + type: 'start', + title: 'Start', + variables: [], + }, + }, + ], + getBeforeNodesInSameBranchIncludeParent: () => [ + { + id: 'start', + data: { + type: 'start', + title: 'Start', + variables: [], + }, + }, + ], + }), + } +}) + +vi.mock('../../../../hooks/use-inspect-vars-crud', () => ({ default: () => ({ appendNodeInspectVars: vi.fn(), invalidateSysVarValues: vi.fn(), diff --git a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts index 7687a5f5502..bdd628d7459 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-available-var-list.ts @@ -1,11 +1,12 @@ import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' import { useSnippetDraftStore } from '@/app/components/snippets/draft-store' -import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { FlowType } from '@/types/common' +import { useIsChatMode, useWorkflow } from '../../../hooks/use-workflow' +import { useWorkflowVariables } from '../../../hooks/use-workflow-variables' import { inputVarTypeToVarType } from '../../data-source/utils' import { appendSnippetInputFieldVars, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts index 7dcf6c237d9..7164803998e 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-node-crud.ts @@ -1,6 +1,6 @@ import type { CommonNodeType } from '@/app/components/workflow/types' import { useCallback, useEffect, useRef } from 'react' -import { useNodeDataUpdate } from '@/app/components/workflow/hooks' +import { useNodeDataUpdate } from '../../../hooks/use-node-data-update' const useNodeCrud = (id: string, data: CommonNodeType) => { const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() diff --git a/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts b/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts index d97a87bfba0..a52d1efb8c8 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-node-help-link.ts @@ -1,6 +1,6 @@ import type { BlockEnum } from '@/app/components/workflow/types' import { useMemo } from 'react' -import { useNodesMetaData } from '@/app/components/workflow/hooks' +import { useNodesMetaData } from '../../../hooks/use-nodes-meta-data' export const useNodeHelpLink = (nodeType: BlockEnum) => { const availableNodesMetaData = useNodesMetaData() diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index a1172a9181e..feab26bb1df 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -17,8 +17,6 @@ import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { trackEvent } from '@/app/components/base/amplitude' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' -import { useIsChatMode, useNodeDataUpdate, useWorkflow } from '@/app/components/workflow/hooks' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import { getNodeInfoById, isConversationVar, @@ -65,6 +63,9 @@ import { getLoopSingleNodeRunUrl, singleNodeRun, } from '@/service/workflow' +import useInspectVarsCrud from '../../../hooks/use-inspect-vars-crud' +import { useNodeDataUpdate } from '../../../hooks/use-node-data-update' +import { useIsChatMode, useWorkflow } from '../../../hooks/use-workflow' import useMatchSchemaType from '../components/variable/use-match-schema-type' const { checkValid: checkLLMValid } = LLMDefault diff --git a/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts b/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts index 589448e24fa..20727d2bdec 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-output-var-list.ts @@ -3,11 +3,11 @@ import type { ValueSelector } from '@/app/components/workflow/types' import { useBoolean, useDebounceFn } from 'ahooks' import { produce } from 'immer' import { useCallback, useRef, useState } from 'react' -import { useWorkflow } from '@/app/components/workflow/hooks' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' import { getDefaultValue } from '@/app/components/workflow/nodes/_base/components/error-handle/utils' import { BlockEnum, VarType } from '@/app/components/workflow/types' import useInspectVarsCrud from '../../../hooks/use-inspect-vars-crud' +import { useWorkflow } from '../../../hooks/use-workflow' type Params = { id: string diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index fc317f89b93..7f129e531c3 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -9,9 +9,6 @@ import { UserAvatarList } from '@/app/components/base/user-avatar-list' import BlockIcon from '@/app/components/workflow/block-icon' import { ToolType } from '@/app/components/workflow/block-selector/types' import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration' -import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' -import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions' import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions' import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id' @@ -19,6 +16,10 @@ import { useStore } from '@/app/components/workflow/store' import { BlockEnum, ControlMode, NodeRunningStatus } from '@/app/components/workflow/types' import { hasErrorHandleNode, hasRetryNode } from '@/app/components/workflow/utils' import { userProfileAtom } from '@/context/account-state' +import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { useNodePluginInstallation } from '../../hooks/use-node-plugin-installation' +import { useToolIcon } from '../../hooks/use-tool-icon' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { selectWorkflowNode } from '../../utils/node-navigation' import AddVariablePopupWithPosition from './components/add-variable-popup-with-position' import EntryNodeContainer, { StartNodeTypeEnum } from './components/entry-node-container' diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index aba83d5bc83..6a722f72abb 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -309,13 +309,26 @@ vi.mock('../../_base/hooks/use-available-var-list', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodeDataUpdate: () => ({ - handleNodeDataUpdate: mockHandleNodeDataUpdate, - handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, - }), - useWorkflowVariableType: () => vi.fn(), -})) +vi.mock('../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: () => ({ + handleNodeDataUpdate: mockHandleNodeDataUpdate, + handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, + }), + } +}) + +vi.mock('../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariableType: () => vi.fn(), + } +}) vi.mock('@/app/components/workflow/hooks-store', () => ({ useHooksStore: (selector: (state: { configsMap: typeof mockConfigsMap }) => unknown) => diff --git a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts index 4360a719a59..64fef972ff5 100644 --- a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts +++ b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts @@ -9,7 +9,6 @@ import isEqual from 'fast-deep-equal' import { useStore as useJotaiStore, useSetAtom } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useHooksStore } from '@/app/components/workflow/hooks-store' -import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { agentSoulConfigToFormState, formStateToAgentSoulConfig, @@ -22,6 +21,7 @@ import { } from '@/features/agent-v2/agent-composer/store' import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' +import { useSerialAsyncCallback } from '../../hooks/use-serial-async-callback' const DRAFT_AUTOSAVE_WAIT = 5000 diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx index 9df4317ffb4..86457d4e12b 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-task-field.tsx @@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' import PromptEditor from '@/app/components/base/prompt-editor' import { $createCustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node' -import { useWorkflowVariableType } from '../../../hooks' +import { useWorkflowVariableType } from '../../../hooks/use-workflow-variables' import { BlockEnum } from '../../../types' import useAvailableVarList from '../../_base/hooks/use-available-var-list' diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index ced3ebdf092..893b1abf1b7 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -17,11 +17,11 @@ import { extractAgentOutputNames, replaceAgentOutputName, } from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils' -import { useNodeDataUpdate } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useStore } from '@/app/components/workflow/store' import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' +import { useNodeDataUpdate } from '../../hooks/use-node-data-update' import useNodeCrud from '../_base/hooks/use-node-crud' import { WorkflowInlineAgentConfigureWorkspace, diff --git a/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts index e6efc7981a2..4013aa34dd6 100644 --- a/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/agent/__tests__/use-config.spec.ts @@ -17,10 +17,15 @@ const mockUseCheckInstalled = vi.hoisted(() => vi.fn()) const mockGenerateAgentToolValue = vi.hoisted(() => vi.fn()) const mockToolParametersToFormSchemas = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: (...args: unknown[]) => mockUseNodesReadOnly(...args), - useIsChatMode: (...args: unknown[]) => mockUseIsChatMode(...args), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: (...args: unknown[]) => mockUseNodesReadOnly(...args), + useIsChatMode: (...args: unknown[]) => mockUseIsChatMode(...args), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/agent/use-config.ts b/web/app/components/workflow/nodes/agent/use-config.ts index 790fc9d9fae..138e7e9589e 100644 --- a/web/app/components/workflow/nodes/agent/use-config.ts +++ b/web/app/components/workflow/nodes/agent/use-config.ts @@ -8,9 +8,9 @@ import { generateAgentToolValue, toolParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' -import { useIsChatMode, useNodesReadOnly } from '@/app/components/workflow/hooks' import { useCheckInstalled, useFetchPluginsInMarketPlaceByIds } from '@/service/use-plugins' import { useStrategyProviderDetail } from '@/service/use-strategy' +import { useIsChatMode, useNodesReadOnly } from '../../hooks/use-workflow' import { VarType as VarKindType } from '../../types' import useAvailableVarList from '../_base/hooks/use-available-var-list' import useNodeCrud from '../_base/hooks/use-node-crud' diff --git a/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx index a2ecc6cf429..0c3eb3ccfd6 100644 --- a/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/answer/__tests__/node.spec.tsx @@ -2,12 +2,13 @@ import type { AnswerNodeType } from '../types' import { screen } from '@testing-library/react' import { createNode } from '@/app/components/workflow/__tests__/fixtures' import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { useWorkflow } from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' +import { useWorkflow } from '../../../hooks/use-workflow' import Node from '../node' -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useWorkflow: vi.fn(), diff --git a/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts index f0b0b074ae3..fdb531bc553 100644 --- a/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/answer/__tests__/use-config.spec.ts @@ -7,9 +7,14 @@ const mockUseNodesReadOnly = vi.hoisted(() => vi.fn()) const mockUseNodeCrud = vi.hoisted(() => vi.fn()) const mockUseVarList = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => mockUseNodesReadOnly(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => mockUseNodesReadOnly(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/answer/use-config.ts b/web/app/components/workflow/nodes/answer/use-config.ts index e62e17354b7..59c0f0a4083 100644 --- a/web/app/components/workflow/nodes/answer/use-config.ts +++ b/web/app/components/workflow/nodes/answer/use-config.ts @@ -2,8 +2,8 @@ import type { Var } from '../../types' import type { AnswerNodeType } from './types' import { produce } from 'immer' import { useCallback } from 'react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { VarType } from '../../types' import useVarList from '../_base/hooks/use-var-list' diff --git a/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx index 3a97d63731c..8a94ecb4f5a 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/__tests__/use-config.spec.tsx @@ -9,18 +9,31 @@ const mockSetInputs = vi.hoisted(() => vi.fn()) const mockGetAvailableVars = vi.hoisted(() => vi.fn()) const mockGetCurrentVariableType = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: false }), - useIsChatMode: () => false, - useWorkflow: () => ({ - getBeforeNodesInSameBranchIncludeParent: () => [ - { id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } }, - ], - }), - useWorkflowVariables: () => ({ - getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args), - }), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useIsChatMode: () => false, + useWorkflow: () => ({ + getBeforeNodesInSameBranchIncludeParent: () => [ + { id: 'start-node', data: { title: 'Start', type: BlockEnum.Start } }, + ], + }), + } +}) + +vi.mock('../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args), + }), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ ...createNodeCrudModuleMock(mockSetInputs), diff --git a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx index d05a9a83881..e8401c19d33 100644 --- a/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/components/var-list/__tests__/index.spec.tsx @@ -7,18 +7,32 @@ import { BlockEnum, VarType } from '@/app/components/workflow/types' import { AssignerNodeInputType, WriteMode } from '../../../types' import VarList from '../index' -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => false, - useWorkflow: () => ({ - getTreeLeafNodes: () => [], - getNodeById: () => undefined, - getBeforeNodesInSameBranchIncludeParent: () => [], - }), - useWorkflowVariables: () => ({ - getNodeAvailableVars: () => [], - getCurrentVariableType: () => undefined, - }), -})) +vi.mock('../../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => false, + useWorkflow: () => ({ + getTreeLeafNodes: () => [], + getNodeById: () => undefined, + getBeforeNodesInSameBranchIncludeParent: () => [], + }), + } +}) + +vi.mock('../../../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getNodeAvailableVars: () => [], + getCurrentVariableType: () => undefined, + }), + } +}) const sourceNode = createNode({ id: 'node-a', diff --git a/web/app/components/workflow/nodes/assigner/hooks.ts b/web/app/components/workflow/nodes/assigner/hooks.ts index e2d5508492f..9fa942064d4 100644 --- a/web/app/components/workflow/nodes/assigner/hooks.ts +++ b/web/app/components/workflow/nodes/assigner/hooks.ts @@ -2,7 +2,8 @@ import type { Node, Var } from '../../types' import { uniqBy } from 'es-toolkit/compat' import { useCallback } from 'react' import { useNodes } from 'reactflow' -import { useIsChatMode, useWorkflow, useWorkflowVariables } from '../../hooks' +import { useIsChatMode, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' import { AssignerNodeInputType, WriteMode } from './types' export const useGetAvailableVars = () => { diff --git a/web/app/components/workflow/nodes/assigner/use-config.ts b/web/app/components/workflow/nodes/assigner/use-config.ts index 50015be77c1..c582fcaefb8 100644 --- a/web/app/components/workflow/nodes/assigner/use-config.ts +++ b/web/app/components/workflow/nodes/assigner/use-config.ts @@ -2,13 +2,9 @@ import type { ValueSelector, Var } from '../../types' import type { AssignerNodeOperation, AssignerNodeType } from './types' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' import { useGetAvailableVars } from './hooks' import { WriteMode, writeModeTypesNum } from './types' import { diff --git a/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts index b77f7ad26d7..3f1dc0a4c0e 100644 --- a/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/code/__tests__/use-config.spec.ts @@ -1,19 +1,24 @@ import type { CodeNodeType, OutputVar } from '../types' import type { Var, Variable } from '@/app/components/workflow/types' import { act, renderHook, waitFor } from '@testing-library/react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, VarType } from '@/app/components/workflow/types' import { fetchNodeDefault, fetchPipelineNodeDefault } from '@/service/workflow' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import useOutputVarList from '../../_base/hooks/use-output-var-list' import useVarList from '../../_base/hooks/use-var-list' import { CodeLanguage } from '../types' import useConfig from '../use-config' -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/code/use-config.ts b/web/app/components/workflow/nodes/code/use-config.ts index c413696f320..ea56af2c2bb 100644 --- a/web/app/components/workflow/nodes/code/use-config.ts +++ b/web/app/components/workflow/nodes/code/use-config.ts @@ -2,9 +2,9 @@ import type { Var, Variable } from '../../types' import type { CodeNodeType, OutputVar } from './types' import { produce } from 'immer' import { useCallback, useEffect, useState } from 'react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { fetchNodeDefault, fetchPipelineNodeDefault } from '@/service/workflow' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { useStore } from '../../store' import { BlockEnum, VarType } from '../../types' import useOutputVarList from '../_base/hooks/use-output-var-list' diff --git a/web/app/components/workflow/nodes/data-source-empty/hooks.ts b/web/app/components/workflow/nodes/data-source-empty/hooks.ts index 6973e94b143..a69bb5a0909 100644 --- a/web/app/components/workflow/nodes/data-source-empty/hooks.ts +++ b/web/app/components/workflow/nodes/data-source-empty/hooks.ts @@ -1,9 +1,9 @@ import type { OnSelectBlock } from '@/app/components/workflow/types' import { produce } from 'immer' import { useCallback } from 'react' -import { useNodesMetaData } from '@/app/components/workflow/hooks' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { generateNewNode } from '@/app/components/workflow/utils' +import { useCollaborativeWorkflow } from '../../hooks/use-collaborative-workflow' +import { useNodesMetaData } from '../../hooks/use-nodes-meta-data' export const useReplaceDataSourceNode = (id: string) => { const collaborativeWorkflow = useCollaborativeWorkflow() diff --git a/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx index a7b708d1d16..3f2b90798a7 100644 --- a/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/__tests__/node.spec.tsx @@ -1,7 +1,7 @@ import type { DataSourceNodeType } from '../types' import { render, screen } from '@testing-library/react' -import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import { BlockEnum } from '@/app/components/workflow/types' +import { useNodePluginInstallation } from '../../../hooks/use-node-plugin-installation' import Node from '../node' const mockInstallPluginButton = vi.hoisted(() => @@ -10,7 +10,7 @@ const mockInstallPluginButton = vi.hoisted(() => )), ) -vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ +vi.mock('../../../hooks/use-node-plugin-installation', () => ({ useNodePluginInstallation: vi.fn(), })) diff --git a/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx index 93484f38bd2..cce6f97fdd3 100644 --- a/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/__tests__/panel.spec.tsx @@ -3,9 +3,9 @@ import type { DataSourceNodeType } from '../types' import type { NodePanelProps } from '@/app/components/workflow/types' import { fireEvent, render, screen } from '@testing-library/react' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import useMatchSchemaType, { getMatchedSchemaType, } from '../../_base/components/variable/use-match-schema-type' @@ -36,9 +36,14 @@ vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ toolParametersToFormSchemas: vi.fn(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + } +}) vi.mock('@/app/components/workflow/store', () => ({ useStore: vi.fn(), diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx index bb72164938a..bb05f8dc2a3 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.branches.spec.tsx @@ -12,7 +12,8 @@ import { useDatasourceSingleRun } from '@/service/use-pipeline' import { useInvalidLastRun } from '@/service/use-workflow' import { fetchNodeInspectVars } from '@/service/workflow' import { FlowType } from '@/types/common' -import { useNodeDataUpdate, useNodesSyncDraft } from '../../../../hooks' +import { useNodeDataUpdate } from '../../../../hooks/use-node-data-update' +import { useNodesSyncDraft } from '../../../../hooks/use-nodes-sync-draft' import useBeforeRunForm from '../use-before-run-form' type DataSourceStoreState = { @@ -58,10 +59,23 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodeDataUpdate: vi.fn(), - useNodesSyncDraft: vi.fn(), -})) +vi.mock('../../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: vi.fn(), + } +}) + +vi.mock('../../../../hooks/use-nodes-sync-draft', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesSyncDraft: vi.fn(), + } +}) vi.mock('@/service/use-pipeline', () => ({ useDatasourceSingleRun: vi.fn(), diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx index 971601a6ba6..9c73404978a 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-before-run-form.spec.tsx @@ -13,7 +13,8 @@ import { useInvalidLastRun } from '@/service/use-workflow' import { fetchNodeInspectVars } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { FlowType } from '@/types/common' -import { useNodeDataUpdate, useNodesSyncDraft } from '../../../../hooks' +import { useNodeDataUpdate } from '../../../../hooks/use-node-data-update' +import { useNodesSyncDraft } from '../../../../hooks/use-nodes-sync-draft' import useBeforeRunForm from '../use-before-run-form' type DataSourceStoreState = { @@ -59,10 +60,23 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodeDataUpdate: vi.fn(), - useNodesSyncDraft: vi.fn(), -})) +vi.mock('../../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: vi.fn(), + } +}) + +vi.mock('../../../../hooks/use-nodes-sync-draft', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesSyncDraft: vi.fn(), + } +}) vi.mock('@/service/use-pipeline', () => ({ useDatasourceSingleRun: vi.fn(), diff --git a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts index 7c9b58ca5f7..73937c024bd 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/__tests__/use-config.spec.ts @@ -10,9 +10,14 @@ vi.mock('reactflow', () => ({ useStoreApi: () => mockUseStoreApi(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodeDataUpdate: () => mockUseNodeDataUpdate(), -})) +vi.mock('../../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: () => mockUseNodeDataUpdate(), + } +}) const createNode = ( overrides: Partial = {}, diff --git a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts index 7db7f958257..9489071ff44 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/use-before-run-form.ts @@ -13,7 +13,8 @@ import { useInvalidLastRun } from '@/service/use-workflow' import { fetchNodeInspectVars } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { FlowType } from '@/types/common' -import { useNodeDataUpdate, useNodesSyncDraft } from '../../../hooks' +import { useNodeDataUpdate } from '../../../hooks/use-node-data-update' +import { useNodesSyncDraft } from '../../../hooks/use-nodes-sync-draft' import { NodeRunningStatus } from '../../../types' const useBeforeRunForm = ({ diff --git a/web/app/components/workflow/nodes/data-source/hooks/use-config.ts b/web/app/components/workflow/nodes/data-source/hooks/use-config.ts index 0f4484d2bf0..b4904b37612 100644 --- a/web/app/components/workflow/nodes/data-source/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/data-source/hooks/use-config.ts @@ -1,7 +1,7 @@ import type { DataSourceNodeType, ToolVarInputs } from '../types' import { useCallback, useEffect, useMemo } from 'react' import { useStoreApi } from 'reactflow' -import { useNodeDataUpdate } from '@/app/components/workflow/hooks' +import { useNodeDataUpdate } from '../../../hooks/use-node-data-update' export const useConfig = (id: string, dataSourceList?: any[]) => { const store = useStoreApi() diff --git a/web/app/components/workflow/nodes/data-source/node.tsx b/web/app/components/workflow/nodes/data-source/node.tsx index c2c430402f9..9d5aa65a7c7 100644 --- a/web/app/components/workflow/nodes/data-source/node.tsx +++ b/web/app/components/workflow/nodes/data-source/node.tsx @@ -2,8 +2,8 @@ import type { FC } from 'react' import type { DataSourceNodeType } from './types' import type { NodeProps } from '@/app/components/workflow/types' import { memo } from 'react' -import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '../../hooks/use-node-plugin-installation' const Node: FC> = ({ data }) => { const { isChecking, isMissing, uniqueIdentifier, canInstall, onInstallSuccess } = diff --git a/web/app/components/workflow/nodes/data-source/panel.tsx b/web/app/components/workflow/nodes/data-source/panel.tsx index 9d9f10dc016..8a9abf4d7a3 100644 --- a/web/app/components/workflow/nodes/data-source/panel.tsx +++ b/web/app/components/workflow/nodes/data-source/panel.tsx @@ -5,12 +5,12 @@ import { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import TagInput from '@/app/components/base/tag-input' import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import { BoxGroupField } from '@/app/components/workflow/nodes/_base/components/layout' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import StructureOutputItem from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show' import { useStore } from '@/app/components/workflow/store' import { wrapStructuredVarItem } from '@/app/components/workflow/utils/tool' +import { useNodesReadOnly } from '../../hooks/use-workflow' import useMatchSchemaType, { getMatchedSchemaType, } from '../_base/components/variable/use-match-schema-type' diff --git a/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts index a86e9c194ea..e181164fa73 100644 --- a/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/document-extractor/__tests__/use-config.spec.ts @@ -1,14 +1,10 @@ import type { DocExtractorNodeType } from '../types' import { renderHook } from '@testing-library/react' import { useStoreApi } from 'reactflow' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../../hooks/use-workflow' +import { useWorkflowVariables } from '../../../hooks/use-workflow-variables' import useConfig from '../use-config' const mockUseStoreApi = vi.mocked(useStoreApi) @@ -26,12 +22,25 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: vi.fn(), - useNodesReadOnly: vi.fn(), - useWorkflow: vi.fn(), - useWorkflowVariables: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: vi.fn(), + useNodesReadOnly: vi.fn(), + useWorkflow: vi.fn(), + } +}) + +vi.mock('../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: vi.fn(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/document-extractor/use-config.ts b/web/app/components/workflow/nodes/document-extractor/use-config.ts index 9ad307cbd94..a54a310f132 100644 --- a/web/app/components/workflow/nodes/document-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/document-extractor/use-config.ts @@ -3,13 +3,9 @@ import type { DocExtractorNodeType } from './types' import { produce } from 'immer' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' import { VarType } from '../../types' const useConfig = (id: string, payload: DocExtractorNodeType) => { diff --git a/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx index 988143b7c0e..87c1050eda2 100644 --- a/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/end/__tests__/node.spec.tsx @@ -2,20 +2,30 @@ import type { EndNodeType } from '../types' import { screen } from '@testing-library/react' import { createNode, createStartNode } from '@/app/components/workflow/__tests__/fixtures' import { renderNodeComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' +import { useIsChatMode, useWorkflow } from '../../../hooks/use-workflow' +import { useWorkflowVariables } from '../../../hooks/use-workflow-variables' import Node from '../node' -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useWorkflow: vi.fn(), - useWorkflowVariables: vi.fn(), useIsChatMode: vi.fn(), } }) +vi.mock('../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: vi.fn(), + } +}) + const mockUseWorkflow = vi.mocked(useWorkflow) const mockUseWorkflowVariables = vi.mocked(useWorkflowVariables) const mockUseIsChatMode = vi.mocked(useIsChatMode) diff --git a/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts index 1ea561177e6..479296bb08d 100644 --- a/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/end/__tests__/use-config.spec.ts @@ -7,9 +7,14 @@ const mockUseNodesReadOnly = vi.hoisted(() => vi.fn()) const mockUseNodeCrud = vi.hoisted(() => vi.fn()) const mockUseVarList = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => mockUseNodesReadOnly(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => mockUseNodesReadOnly(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/end/node.tsx b/web/app/components/workflow/nodes/end/node.tsx index 95374c96312..cc93492ce15 100644 --- a/web/app/components/workflow/nodes/end/node.tsx +++ b/web/app/components/workflow/nodes/end/node.tsx @@ -2,9 +2,10 @@ import type { FC } from 'react' import type { EndNodeType } from './types' import type { NodeProps, Variable } from '@/app/components/workflow/types' import * as React from 'react' -import { useIsChatMode, useWorkflow, useWorkflowVariables } from '@/app/components/workflow/hooks' import { VariableLabelInNode } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { BlockEnum } from '@/app/components/workflow/types' +import { useIsChatMode, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' const Node: FC> = ({ id, data }) => { const { getBeforeNodesInSameBranch } = useWorkflow() diff --git a/web/app/components/workflow/nodes/end/use-config.ts b/web/app/components/workflow/nodes/end/use-config.ts index 93a60e620b7..6aeb96daa7f 100644 --- a/web/app/components/workflow/nodes/end/use-config.ts +++ b/web/app/components/workflow/nodes/end/use-config.ts @@ -1,6 +1,6 @@ import type { EndNodeType } from './types' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '../../hooks/use-workflow' import useVarList from '../_base/hooks/use-var-list' const useConfig = (id: string, payload: EndNodeType) => { diff --git a/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts index 6105326ab2d..cf134b7f16e 100644 --- a/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/http/__tests__/use-config.spec.ts @@ -1,17 +1,22 @@ import type { HttpNodeType } from '../types' import { act, renderHook, waitFor } from '@testing-library/react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import useVarList from '../../_base/hooks/use-var-list' import useKeyValueList from '../hooks/use-key-value-list' import { APIType, AuthorizationType, BodyPayloadValueType, BodyType, Method } from '../types' import useConfig from '../use-config' -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx index b31e2e7840a..f71af79238a 100644 --- a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx +++ b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx @@ -10,11 +10,16 @@ const { mockHandleNodeSelect, mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesInteractions: () => ({ - handleNodeSelect: mockHandleNodeSelect, - }), -})) +vi.mock('../../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, + }), + } +}) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { diff --git a/web/app/components/workflow/nodes/http/components/curl-panel.tsx b/web/app/components/workflow/nodes/http/components/curl-panel.tsx index e23a374f2ea..d1740095147 100644 --- a/web/app/components/workflow/nodes/http/components/curl-panel.tsx +++ b/web/app/components/workflow/nodes/http/components/curl-panel.tsx @@ -8,7 +8,7 @@ import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNodesInteractions } from '@/app/components/workflow/hooks' +import { useNodesInteractions } from '../../../hooks/use-nodes-interactions' import { parseCurl } from './curl-parser' type Props = Readonly<{ diff --git a/web/app/components/workflow/nodes/http/use-config.ts b/web/app/components/workflow/nodes/http/use-config.ts index 8e745fa4192..7718f5ead61 100644 --- a/web/app/components/workflow/nodes/http/use-config.ts +++ b/web/app/components/workflow/nodes/http/use-config.ts @@ -3,8 +3,8 @@ import type { Authorization, Body, HttpNodeType, Method, Timeout } from './types import { useBoolean } from 'ahooks' import { produce } from 'immer' import { useCallback, useEffect, useState } from 'react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { useStore } from '../../store' import { VarType } from '../../types' import useVarList from '../_base/hooks/use-var-list' diff --git a/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx index f1b59ba8f40..ed1a366a114 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/human-input.spec.tsx @@ -48,20 +48,41 @@ vi.mock('@/app/components/workflow/store', () => ({ })) // Mock workflow hooks barrel (used by NodeSourceHandle via ../../../hooks) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesInteractions: () => ({ - handleNodeAdd: vi.fn(), - }), - useNodesReadOnly: () => ({ - getNodesReadOnly: () => false, - nodesReadOnly: false, - }), - useAvailableBlocks: () => ({ - availableNextBlocks: [], - availablePrevBlocks: [], - }), - useIsChatMode: () => false, -})) +vi.mock('../../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useAvailableBlocks: () => ({ + availableNextBlocks: [], + availablePrevBlocks: [], + }), + } +}) + +vi.mock('../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesInteractions: () => ({ + handleNodeAdd: vi.fn(), + }), + } +}) + +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ + getNodesReadOnly: () => false, + nodesReadOnly: false, + }), + useIsChatMode: () => false, + } +}) // ── Factory: Build a realistic human-input node as it would appear after DSL import ── const createHumanInputNode = (overrides?: Partial): Node => ({ diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx index d1eeb072942..c20f48cfd89 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/form-content.spec.tsx @@ -27,9 +27,14 @@ vi.mock('react-i18next', async () => { } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useWorkflowVariableType: () => mockUseWorkflowVariableType(), -})) +vi.mock('../../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariableType: () => mockUseWorkflowVariableType(), + } +}) vi.mock('@tanstack/react-hotkeys', async (importOriginal) => { const actual = await importOriginal() diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx index 3aad1dd33db..45980de8b39 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx @@ -11,9 +11,14 @@ vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesSyncDraft: () => mockUseNodesSyncDraft(), -})) +vi.mock('../../../../../hooks/use-nodes-sync-draft', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesSyncDraft: () => mockUseNodesSyncDraft(), + } +}) vi.mock('../method-selector', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx index 771c4e8cc7d..8a2c0fd301b 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx @@ -4,7 +4,7 @@ import { produce } from 'immer' import * as React from 'react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks' +import { useNodesSyncDraft } from '../../../../hooks/use-nodes-sync-draft' import MethodItem from './method-item' import MethodSelector from './method-selector' import { UpgradeModal } from './upgrade-modal' diff --git a/web/app/components/workflow/nodes/human-input/components/form-content.tsx b/web/app/components/workflow/nodes/human-input/components/form-content.tsx index bdc4c2b1ed7..1b9e92b7b93 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content.tsx @@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import { Trans, useTranslation } from 'react-i18next' import PromptEditor from '@/app/components/base/prompt-editor' import { INSERT_HITL_INPUT_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/hitl-input-block' -import { useWorkflowVariableType } from '../../../hooks' +import { useWorkflowVariableType } from '../../../hooks/use-workflow-variables' import { BlockEnum } from '../../../types' import AddInputField from './add-input-field' diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts index 075136860bc..8f4d4bafebb 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-config.spec.ts @@ -13,11 +13,16 @@ vi.mock('reactflow', () => ({ useUpdateNodeInternals: () => mockUseUpdateNodeInternals(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => mockUseNodesReadOnly(), -})) +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() -vi.mock('@/app/components/workflow/hooks/use-edges-interactions', () => ({ + return { + ...actual, + useNodesReadOnly: () => mockUseNodesReadOnly(), + } +}) + +vi.mock('../../../../hooks/use-edges-interactions', () => ({ useEdgesInteractions: () => mockUseEdgesInteractions(), })) diff --git a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts index 7478b789aac..2f90ef8c09a 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/__tests__/use-form-content.spec.ts @@ -6,9 +6,14 @@ import useFormContent from '../use-form-content' const mockUseWorkflow = vi.hoisted(() => vi.fn()) const mockUseNodeCrud = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useWorkflow: () => mockUseWorkflow(), -})) +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflow: () => mockUseWorkflow(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-config.ts b/web/app/components/workflow/nodes/human-input/hooks/use-config.ts index 13fd18eae53..64dfdcf2083 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/use-config.ts @@ -2,9 +2,9 @@ import type { DeliveryMethod, HumanInputNodeType, UserAction } from '../types' import { produce } from 'immer' import { useState } from 'react' import { useUpdateNodeInternals } from 'reactflow' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' -import { useEdgesInteractions } from '@/app/components/workflow/hooks/use-edges-interactions' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useEdgesInteractions } from '../../../hooks/use-edges-interactions' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import useFormContent from './use-form-content' const useConfig = (id: string, payload: HumanInputNodeType) => { diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts index 8317285473a..eaf659933f4 100644 --- a/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts +++ b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts @@ -1,7 +1,7 @@ import type { FormInputItem, HumanInputNodeType } from '../types' import { produce } from 'immer' import { useCallback, useEffect, useRef, useState } from 'react' -import { useWorkflow } from '@/app/components/workflow/hooks' +import { useWorkflow } from '../../../hooks/use-workflow' import useNodeCrud from '../../_base/hooks/use-node-crud' const useFormContent = (id: string, payload: HumanInputNodeType) => { diff --git a/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx index 64708ec4362..326178dc5e1 100644 --- a/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/if-else/__tests__/use-config.spec.tsx @@ -26,13 +26,26 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: false }), - useEdgesInteractions: () => ({ - handleEdgeDeleteByDeleteBranch: (...args: unknown[]) => - mockHandleEdgeDeleteByDeleteBranch(...args), - }), -})) +vi.mock('../../../hooks/use-edges-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useEdgesInteractions: () => ({ + handleEdgeDeleteByDeleteBranch: (...args: unknown[]) => + mockHandleEdgeDeleteByDeleteBranch(...args), + }), + } +}) + +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: false }), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ ...createNodeCrudModuleMock(mockSetInputs), diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx index b83f8983482..99dd1c3d559 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-item.tsx @@ -22,7 +22,6 @@ import { produce } from 'immer' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' -import { useIsChatMode } from '@/app/components/workflow/hooks/use-workflow' import { getVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils' import BoolValue from '@/app/components/workflow/panel/chat-variable-panel/components/bool-value' import { useWorkflowStore } from '@/app/components/workflow/store' @@ -33,6 +32,7 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' +import { useIsChatMode } from '../../../../hooks/use-workflow' import useMatchSchemaType from '../../../_base/components/variable/use-match-schema-type' import { FILE_TYPE_OPTIONS, SUB_VARIABLES, TRANSFER_METHOD } from '../../../constants' import { ComparisonOperator } from '../../types' diff --git a/web/app/components/workflow/nodes/if-else/use-config.ts b/web/app/components/workflow/nodes/if-else/use-config.ts index 252345f160d..47303ef345e 100644 --- a/web/app/components/workflow/nodes/if-else/use-config.ts +++ b/web/app/components/workflow/nodes/if-else/use-config.ts @@ -12,9 +12,10 @@ import type { } from './types' import { useCallback, useMemo, useRef } from 'react' import { useUpdateNodeInternals } from 'reactflow' -import { useEdgesInteractions, useNodesReadOnly } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useEdgesInteractions } from '../../hooks/use-edges-interactions' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { addCase, addCondition, diff --git a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts b/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts index 1dd60325163..635ef55b51e 100644 --- a/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts +++ b/web/app/components/workflow/nodes/if-else/use-is-var-file-attribute.ts @@ -1,7 +1,8 @@ import type { ValueSelector } from '../../types' import { useMemo } from 'react' import { useStoreApi } from 'reactflow' -import { useIsChatMode, useWorkflow, useWorkflowVariables } from '../../hooks' +import { useIsChatMode, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' import { VarType } from '../../types' type Params = { diff --git a/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx index f7a95fa61e6..dbfef5c8d13 100644 --- a/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/iteration-start/__tests__/index.spec.tsx @@ -3,21 +3,35 @@ import type { CommonNodeType } from '@/app/components/workflow/types' import { render, waitFor } from '@testing-library/react' import { createNode } from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - useAvailableBlocks, - useIsChatMode, - useNodesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableBlocks } from '../../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../../hooks/use-nodes-interactions' +import { useIsChatMode, useNodesReadOnly } from '../../../hooks/use-workflow' import IterationStartNode, { IterationStartNodeDumb } from '../index' -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAvailableBlocks: vi.fn(), + } +}) + +vi.mock('../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, useNodesInteractions: vi.fn(), + } +}) + +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, useNodesReadOnly: vi.fn(), useIsChatMode: vi.fn(), } diff --git a/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx index ea8518fb233..1ccae5d382a 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/iteration/__tests__/integration.spec.tsx @@ -70,13 +70,19 @@ vi.mock('../use-interactions', () => ({ }), })) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-available-blocks', () => ({ useAvailableBlocks: () => ({ availableNextBlocks: [BlockEnum.Code], }), +})) + +vi.mock('../../../hooks/use-nodes-interactions', () => ({ useNodesInteractions: () => ({ handleNodeAdd: mockHandleNodeAdd, }), +})) + +vi.mock('../../../hooks/use-workflow', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly, }), diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts index 728ab58077d..e0b19e6fb10 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-config.spec.ts @@ -17,16 +17,21 @@ const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn()) const mockUseAllMCPTools = vi.hoisted(() => vi.fn()) const mockToNodeOutputVars = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../../hooks/use-inspect-vars-crud', () => ({ __esModule: true, default: (...args: unknown[]) => mockUseInspectVarsCrud(...args), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => mockUseNodesReadOnly(), - useIsChatMode: () => mockUseIsChatMode(), - useWorkflow: () => mockUseWorkflow(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => mockUseNodesReadOnly(), + useIsChatMode: () => mockUseIsChatMode(), + useWorkflow: () => mockUseWorkflow(), + } +}) vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: { dataSourceList: unknown[] }) => unknown) => diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx index e5adb792066..0c3ecac0bef 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-interactions.spec.tsx @@ -21,18 +21,23 @@ vi.mock('reactflow', async () => { }), } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesMetaData: () => ({ - nodesMap: { - [BlockEnum.Code]: { - defaultValue: { - title: 'Code', - desc: '', +vi.mock('../../../hooks/use-nodes-meta-data', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesMetaData: () => ({ + nodesMap: { + [BlockEnum.Code]: { + defaultValue: { + title: 'Code', + desc: '', + }, }, }, - }, - }), -})) + }), + } +}) vi.mock('@/app/components/workflow/utils', () => ({ generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args), diff --git a/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts index 657adc1d8e5..48baecee7d0 100644 --- a/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/iteration/__tests__/use-single-run-form-params.spec.ts @@ -13,10 +13,15 @@ const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn()) const mockGetNodeInfoById = vi.hoisted(() => vi.fn()) const mockIsSystemVar = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsNodeInIteration: (...args: unknown[]) => mockUseIsNodeInIteration(...args), - useWorkflow: () => mockUseWorkflow(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsNodeInIteration: (...args: unknown[]) => mockUseIsNodeInIteration(...args), + useWorkflow: () => mockUseWorkflow(), + } +}) vi.mock('@/app/components/workflow/run/utils/format-log', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/iteration/add-block.tsx b/web/app/components/workflow/nodes/iteration/add-block.tsx index 0bfa5b3a55b..3870cf1d5d3 100644 --- a/web/app/components/workflow/nodes/iteration/add-block.tsx +++ b/web/app/components/workflow/nodes/iteration/add-block.tsx @@ -7,7 +7,9 @@ import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' import { BlockEnum } from '@/app/components/workflow/types' -import { useAvailableBlocks, useNodesInteractions, useNodesReadOnly } from '../../hooks' +import { useAvailableBlocks } from '../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../hooks/use-nodes-interactions' +import { useNodesReadOnly } from '../../hooks/use-workflow' type AddBlockProps = { iterationNodeId: string diff --git a/web/app/components/workflow/nodes/iteration/use-config.ts b/web/app/components/workflow/nodes/iteration/use-config.ts index fca9b95fe4a..05edb30c935 100644 --- a/web/app/components/workflow/nodes/iteration/use-config.ts +++ b/web/app/components/workflow/nodes/iteration/use-config.ts @@ -10,8 +10,8 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { useStore } from '../../store' import { VarType } from '../../types' import { toNodeOutputVars } from '../_base/components/variable/utils' diff --git a/web/app/components/workflow/nodes/iteration/use-interactions.ts b/web/app/components/workflow/nodes/iteration/use-interactions.ts index e07a219bf3e..2506370f554 100644 --- a/web/app/components/workflow/nodes/iteration/use-interactions.ts +++ b/web/app/components/workflow/nodes/iteration/use-interactions.ts @@ -2,8 +2,8 @@ import type { BlockEnum, ChildNodeTypeCount, Node } from '../../types' import { produce } from 'immer' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useNodesMetaData } from '@/app/components/workflow/hooks' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' +import { useCollaborativeWorkflow } from '../../hooks/use-collaborative-workflow' +import { useNodesMetaData } from '../../hooks/use-nodes-meta-data' import { generateNewNode, getNodeCustomTypeByNodeDataType } from '../../utils' import { buildIterationChildCopy, diff --git a/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts b/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts index c95068ade95..971fa35cbe8 100644 --- a/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/iteration/use-single-run-form-params.ts @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import formatTracing from '@/app/components/workflow/run/utils/format-log' import { InputVarType, VarType } from '@/app/components/workflow/types' import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config' -import { useIsNodeInIteration, useWorkflow } from '../../hooks' +import { useIsNodeInIteration, useWorkflow } from '../../hooks/use-workflow' import { getNodeInfoById, getNodeUsedVarPassToServerKey, diff --git a/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx index b30dd0c5df1..2b84b8c9b1e 100644 --- a/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/__tests__/panel.spec.tsx @@ -54,9 +54,14 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useModelList: mockUseModelList, })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: false }), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: false }), + } +}) vi.mock('../hooks/use-config', () => ({ useConfig: () => ({ diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx index 8b230ca3d46..0ab46d1b580 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/__tests__/use-config.spec.tsx @@ -14,11 +14,16 @@ import { useConfig } from '../use-config' const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodeDataUpdate: () => ({ - handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, - }), -})) +vi.mock('../../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: () => ({ + handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, + }), + } +}) const createNodeData = createNodeDataFactory({ title: 'Knowledge Base', diff --git a/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts b/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts index 649d50831f2..350b52568ea 100644 --- a/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-base/hooks/use-config.ts @@ -3,8 +3,8 @@ import type { ValueSelector } from '@/app/components/workflow/types' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' -import { useNodeDataUpdate } from '@/app/components/workflow/hooks' import { DEFAULT_WEIGHTED_SCORE, RerankingModeEnum } from '@/models/datasets' +import { useNodeDataUpdate } from '../../../hooks/use-node-data-update' import { ChunkStructureEnum, HybridSearchModeEnum, diff --git a/web/app/components/workflow/nodes/knowledge-base/panel.tsx b/web/app/components/workflow/nodes/knowledge-base/panel.tsx index 23c8f0d6bbd..312c4216d45 100644 --- a/web/app/components/workflow/nodes/knowledge-base/panel.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/panel.tsx @@ -9,7 +9,6 @@ import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { normalizeModelProviderModelsResponse } from '@/app/components/header/account-setting/model-provider-page/utils' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import { BoxGroup, BoxGroupField, @@ -18,6 +17,7 @@ import { import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { consoleQuery } from '@/service/client' +import { useNodesReadOnly } from '../../hooks/use-workflow' import Split from '../_base/components/split' import ChunkStructure from './components/chunk-structure' import EmbeddingModel from './components/embedding-model' diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts index 851afde5731..3f97777611b 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/use-config.spec.ts @@ -8,7 +8,6 @@ import { useModelListAndDefaultModelAndCurrentProviderAndModel, } from '@/app/components/header/account-setting/model-provider-page/hooks' import { useDatasetsDetailStore } from '@/app/components/workflow/datasets-detail-store/store' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { BlockEnum, VarType } from '@/app/components/workflow/types' @@ -16,6 +15,7 @@ import { DATASET_DEFAULT } from '@/config' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { fetchDatasets } from '@/service/datasets' import { AppModeEnum, RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../../hooks/use-workflow' import { ComparisonOperator, LogicalOperator, @@ -33,11 +33,16 @@ vi.mock('uuid', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), - useIsChatMode: vi.fn(), - useWorkflow: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + useIsChatMode: vi.fn(), + useWorkflow: vi.fn(), + } +}) vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(), diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts index a95eac3d97f..c3d824e9000 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts +++ b/web/app/components/workflow/nodes/knowledge-retrieval/use-config.ts @@ -9,7 +9,7 @@ import { } from '@/app/components/header/account-setting/model-provider-page/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useDatasetsDetailStore } from '../../datasets-detail-store/store' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { BlockEnum } from '../../types' import useKnowledgeDatasetSelection from './hooks/use-knowledge-dataset-selection' import useKnowledgeInputManager from './hooks/use-knowledge-input-manager' diff --git a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx index 8e7eda14cdb..3c96c472cd6 100644 --- a/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/list-operator/__tests__/use-config.spec.tsx @@ -26,16 +26,29 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }), - useIsChatMode: () => mockIsChatMode, - useWorkflow: () => ({ - getBeforeNodesInSameBranch: (...args: unknown[]) => mockGetBeforeNodesInSameBranch(...args), - }), - useWorkflowVariables: () => ({ - getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args), - }), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly }), + useIsChatMode: () => mockIsChatMode, + useWorkflow: () => ({ + getBeforeNodesInSameBranch: (...args: unknown[]) => mockGetBeforeNodesInSameBranch(...args), + }), + } +}) + +vi.mock('../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => ({ + getCurrentVariableType: (...args: unknown[]) => mockGetCurrentVariableType(...args), + }), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ ...createNodeCrudModuleMock(mockSetInputs), diff --git a/web/app/components/workflow/nodes/list-operator/use-config.ts b/web/app/components/workflow/nodes/list-operator/use-config.ts index ff3cca24095..f76bc047de8 100644 --- a/web/app/components/workflow/nodes/list-operator/use-config.ts +++ b/web/app/components/workflow/nodes/list-operator/use-config.ts @@ -2,13 +2,9 @@ import type { ValueSelector, Var } from '../../types' import type { Condition, Limit, ListFilterNodeType, OrderBy } from './types' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' -import { - useIsChatMode, - useNodesReadOnly, - useWorkflow, - useWorkflowVariables, -} from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' import { canFilterVariable, getItemVarType, diff --git a/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts index a78753db44e..9cac2d3f5e7 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/use-config.spec.ts @@ -2,30 +2,35 @@ import type { MutableRefObject } from 'react' import type { LLMNodeType } from '../types' import { act, renderHook, waitFor } from '@testing-library/react' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { useIsChatMode, useNodesReadOnly } from '@/app/components/workflow/hooks' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { AppModeEnum, Resolution } from '@/types/app' import useConfigVision from '../../../hooks/use-config-vision' +import useInspectVarsCrud from '../../../hooks/use-inspect-vars-crud' +import { useIsChatMode, useNodesReadOnly } from '../../../hooks/use-workflow' import useAvailableVarList from '../../_base/hooks/use-available-var-list' import useLLMInputManager from '../hooks/use-llm-input-manager' import useLLMPromptConfig from '../hooks/use-llm-prompt-config' import useLLMStructuredOutputConfig from '../hooks/use-llm-structured-output-config' import useConfig from '../use-config' -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), - useIsChatMode: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + useIsChatMode: vi.fn(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, default: vi.fn(), })) -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../../hooks/use-inspect-vars-crud', () => ({ __esModule: true, default: vi.fn(), })) diff --git a/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts index 6c305a53002..38c02d712f2 100644 --- a/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/llm/__tests__/use-single-run-form-params.spec.ts @@ -30,9 +30,14 @@ vi.mock('../../_base/hooks/use-node-crud', () => ({ default: vi.fn(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => true, -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => true, + } +}) const mockFlowType = vi.hoisted(() => ({ value: undefined as FlowType | undefined, diff --git a/web/app/components/workflow/nodes/llm/use-config.ts b/web/app/components/workflow/nodes/llm/use-config.ts index 38ce1bef8f9..c748b8131f3 100644 --- a/web/app/components/workflow/nodes/llm/use-config.ts +++ b/web/app/components/workflow/nodes/llm/use-config.ts @@ -4,11 +4,11 @@ import { produce } from 'immer' import { useCallback, useEffect, useState } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { AppModeEnum } from '@/types/app' -import { useIsChatMode, useNodesReadOnly } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' +import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { useIsChatMode, useNodesReadOnly } from '../../hooks/use-workflow' import { useStore } from '../../store' import useAvailableVarList from '../_base/hooks/use-available-var-list' import useLLMInputManager from './hooks/use-llm-input-manager' diff --git a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts index b83dfc510dc..fb49995c3f8 100644 --- a/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/llm/use-single-run-form-params.ts @@ -9,8 +9,8 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import { InputVarType, VarType } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' -import { useIsChatMode } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' +import { useIsChatMode } from '../../hooks/use-workflow' import { EditionType } from '../../types' import useAvailableVarList from '../_base/hooks/use-available-var-list' import useNodeCrud from '../_base/hooks/use-node-crud' diff --git a/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx index 61ed49ccc55..25506393cc7 100644 --- a/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/loop-start/__tests__/index.spec.tsx @@ -3,21 +3,35 @@ import type { CommonNodeType } from '@/app/components/workflow/types' import { render, waitFor } from '@testing-library/react' import { createNode } from '@/app/components/workflow/__tests__/fixtures' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' -import { - useAvailableBlocks, - useIsChatMode, - useNodesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' import { BlockEnum } from '@/app/components/workflow/types' +import { useAvailableBlocks } from '../../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../../hooks/use-nodes-interactions' +import { useIsChatMode, useNodesReadOnly } from '../../../hooks/use-workflow' import LoopStartNode, { LoopStartNodeDumb } from '../index' -vi.mock('@/app/components/workflow/hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../hooks/use-available-blocks', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAvailableBlocks: vi.fn(), + } +}) + +vi.mock('../../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, useNodesInteractions: vi.fn(), + } +}) + +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, useNodesReadOnly: vi.fn(), useIsChatMode: vi.fn(), } diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx index 6d85fa1aedd..84261702a96 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/loop/__tests__/use-config.spec.tsx @@ -34,13 +34,18 @@ vi.mock('@/service/use-tools', () => ({ useAllMCPTools: () => ({ data: [] }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: false }), - useIsChatMode: () => false, - useWorkflow: () => ({ - getLoopNodeChildren: (...args: unknown[]) => mockGetLoopNodeChildren(...args), - }), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useIsChatMode: () => false, + useWorkflow: () => ({ + getLoopNodeChildren: (...args: unknown[]) => mockGetLoopNodeChildren(...args), + }), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ ...createNodeCrudModuleMock(mockSetInputs), diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx index 37ba944daee..4cd69deffe7 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx +++ b/web/app/components/workflow/nodes/loop/__tests__/use-interactions.spec.tsx @@ -22,17 +22,22 @@ vi.mock('reactflow', async () => { } }) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesMetaData: () => ({ - nodesMap: { - [BlockEnum.Code]: { - defaultValue: { - title: 'Code', +vi.mock('../../../hooks/use-nodes-meta-data', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesMetaData: () => ({ + nodesMap: { + [BlockEnum.Code]: { + defaultValue: { + title: 'Code', + }, }, }, - }, - }), -})) + }), + } +}) vi.mock('@/app/components/workflow/utils', () => ({ generateNewNode: (...args: unknown[]) => mockGenerateNewNode(...args), diff --git a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts index ba404f70527..bb69323d058 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/loop/__tests__/use-single-run-form-params.spec.ts @@ -20,7 +20,7 @@ const mockGetNodeUsedVarPassToServerKey = vi.hoisted(() => vi.fn()) const mockGetNodeInfoById = vi.hoisted(() => vi.fn()) const mockIsSystemVar = vi.hoisted(() => vi.fn()) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-workflow', () => ({ useIsNodeInLoop: (...args: unknown[]) => mockUseIsNodeInLoop(...args), useWorkflow: () => mockUseWorkflow(), })) diff --git a/web/app/components/workflow/nodes/loop/add-block.tsx b/web/app/components/workflow/nodes/loop/add-block.tsx index 565d8fd80b5..ae761cf741d 100644 --- a/web/app/components/workflow/nodes/loop/add-block.tsx +++ b/web/app/components/workflow/nodes/loop/add-block.tsx @@ -7,7 +7,9 @@ import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import BlockSelector from '@/app/components/workflow/block-selector' import { BlockEnum } from '@/app/components/workflow/types' -import { useAvailableBlocks, useNodesInteractions, useNodesReadOnly } from '../../hooks' +import { useAvailableBlocks } from '../../hooks/use-available-blocks' +import { useNodesInteractions } from '../../hooks/use-nodes-interactions' +import { useNodesReadOnly } from '../../hooks/use-workflow' type AddBlockProps = { loopNodeId: string diff --git a/web/app/components/workflow/nodes/loop/use-config.ts b/web/app/components/workflow/nodes/loop/use-config.ts index b0f76f8232e..118e3095053 100644 --- a/web/app/components/workflow/nodes/loop/use-config.ts +++ b/web/app/components/workflow/nodes/loop/use-config.ts @@ -17,7 +17,7 @@ import { useAllMCPTools, useAllWorkflowTools, } from '@/service/use-tools' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { toNodeOutputVars } from '../_base/components/variable/utils' import useNodeCrud from '../_base/hooks/use-node-crud' import { diff --git a/web/app/components/workflow/nodes/loop/use-interactions.ts b/web/app/components/workflow/nodes/loop/use-interactions.ts index 214e9c7f9c1..aa728b62b3d 100644 --- a/web/app/components/workflow/nodes/loop/use-interactions.ts +++ b/web/app/components/workflow/nodes/loop/use-interactions.ts @@ -1,8 +1,8 @@ import type { BlockEnum, Node } from '../../types' import { produce } from 'immer' import { useCallback } from 'react' -import { useNodesMetaData } from '@/app/components/workflow/hooks' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' +import { useCollaborativeWorkflow } from '../../hooks/use-collaborative-workflow' +import { useNodesMetaData } from '../../hooks/use-nodes-meta-data' import { generateNewNode, getNodeCustomTypeByNodeDataType } from '../../utils' import { buildLoopChildCopy, diff --git a/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts b/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts index 7315cda65cd..c868e95db9d 100644 --- a/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts +++ b/web/app/components/workflow/nodes/loop/use-is-var-file-attribute.ts @@ -1,6 +1,7 @@ import type { ValueSelector } from '../../types' import { useMemo } from 'react' -import { useIsChatMode, useWorkflow, useWorkflowVariables } from '../../hooks' +import { useIsChatMode, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' import { VarType } from '../../types' type Params = { diff --git a/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts b/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts index c4fde98a70a..d38a202e1e4 100644 --- a/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/loop/use-single-run-form-params.ts @@ -5,7 +5,7 @@ import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import formatTracing from '@/app/components/workflow/run/utils/format-log' import { ValueType } from '@/app/components/workflow/types' -import { useIsNodeInLoop, useWorkflow } from '../../hooks' +import { useIsNodeInLoop, useWorkflow } from '../../hooks/use-workflow' import { buildUsedOutVars, createInputVarValues, diff --git a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts index 2a0e6a64f7c..d79d6d79e4e 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/use-config.ts +++ b/web/app/components/workflow/nodes/parameter-extractor/use-config.ts @@ -12,9 +12,9 @@ import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { AppModeEnum } from '@/types/app' import { supportFunctionCall } from '@/utils/tool-call' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { useStore } from '../../store' import { ChangeType, VarType } from '../../types' diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx index 7301a0a72b8..ac53a5a7d09 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/integration.spec.tsx @@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event' import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import { renderWorkflowFlowComponent } from '@/app/components/workflow/__tests__/workflow-test-env' import { BlockEnum, VarType } from '@/app/components/workflow/types' -import { useEdgesInteractions } from '../../../hooks' +import { useEdgesInteractions } from '../../../hooks/use-edges-interactions' import AdvancedSetting from '../components/advanced-setting' import ClassItem from '../components/class-item' import ClassList from '../components/class-list' @@ -43,8 +43,8 @@ vi.mock('../../_base/hooks/use-available-var-list', () => ({ })), })) -vi.mock('../../../hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../hooks/use-edges-interactions', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useEdgesInteractions: vi.fn(), diff --git a/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts index ad0ec634959..c27d5c336ed 100644 --- a/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/question-classifier/__tests__/use-config.spec.ts @@ -1,21 +1,26 @@ import type { QuestionClassifierNodeType } from '../types' import { act, renderHook, waitFor } from '@testing-library/react' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' -import useConfigVision from '@/app/components/workflow/hooks/use-config-vision' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' +import useConfigVision from '../../../hooks/use-config-vision' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../../hooks/use-workflow' import useConfig from '../use-config' -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), - useIsChatMode: vi.fn(), - useWorkflow: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + useIsChatMode: vi.fn(), + useWorkflow: vi.fn(), + } +}) vi.mock('reactflow', () => ({ useUpdateNodeInternals: vi.fn(() => vi.fn()), @@ -47,7 +52,7 @@ vi.mock('@/app/components/workflow/hooks-store/store', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks/use-config-vision', () => ({ +vi.mock('../../../hooks/use-config-vision', () => ({ __esModule: true, default: vi.fn(), })) diff --git a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx index 846a8faeb39..657a673ed07 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/__tests__/class-list.spec.tsx @@ -1,7 +1,7 @@ import type { Topic } from '../../types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useEdgesInteractions } from '../../../../hooks' +import { useEdgesInteractions } from '../../../../hooks/use-edges-interactions' import ClassList from '../class-list' vi.mock('react-sortablejs', () => ({ @@ -9,8 +9,8 @@ vi.mock('react-sortablejs', () => ({ ReactSortable: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock('../../../../hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../../../hooks/use-edges-interactions', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useEdgesInteractions: vi.fn(), diff --git a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx index 6655e4908cc..151e946b671 100644 --- a/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx +++ b/web/app/components/workflow/nodes/question-classifier/components/class-list.tsx @@ -11,7 +11,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ReactSortable } from 'react-sortablejs' import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general' -import { useEdgesInteractions } from '../../../hooks' +import { useEdgesInteractions } from '../../../hooks/use-edges-interactions' import AddButton from '../../_base/components/add-button' import { useInlineLabelHintDismissed } from '../storage' import Item from './class-item' diff --git a/web/app/components/workflow/nodes/question-classifier/use-config.ts b/web/app/components/workflow/nodes/question-classifier/use-config.ts index 074af92eed5..a2c8f9c2434 100644 --- a/web/app/components/workflow/nodes/question-classifier/use-config.ts +++ b/web/app/components/workflow/nodes/question-classifier/use-config.ts @@ -10,8 +10,8 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store/store' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { AppModeEnum } from '@/types/app' import { FlowType } from '@/types/common' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks' import useConfigVision from '../../hooks/use-config-vision' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { useStore } from '../../store' import { BlockEnum, VarType } from '../../types' import useAvailableVarList from '../_base/hooks/use-available-var-list' diff --git a/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx index 70b5ce390fe..28e5e04b630 100644 --- a/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/start-placeholder/__tests__/panel.spec.tsx @@ -34,9 +34,15 @@ vi.mock('@/app/components/workflow/block-selector/all-start-blocks', () => ({ ), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useAutoGenerateWebhookUrl: () => mocks.autoGenerateWebhookUrl, -})) +vi.mock('../../../hooks/use-auto-generate-webhook-url', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + useAutoGenerateWebhookUrl: () => mocks.autoGenerateWebhookUrl, + } +}) vi.mock('@/app/components/workflow/hooks-store', () => ({ useHooksStore: (selector: (state: unknown) => unknown) => @@ -55,7 +61,7 @@ vi.mock('@/app/components/workflow/hooks-store', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ +vi.mock('../../../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: mocks.handleSyncWorkflowDraft, }), diff --git a/web/app/components/workflow/nodes/start-placeholder/panel.tsx b/web/app/components/workflow/nodes/start-placeholder/panel.tsx index 0f775e60afa..5c396a48a3f 100644 --- a/web/app/components/workflow/nodes/start-placeholder/panel.tsx +++ b/web/app/components/workflow/nodes/start-placeholder/panel.tsx @@ -11,11 +11,11 @@ import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import SearchBox from '@/app/components/plugins/marketplace/search-box' import AllStartBlocks from '@/app/components/workflow/block-selector/all-start-blocks' -import { useAutoGenerateWebhookUrl } from '@/app/components/workflow/hooks' import { useHooksStore } from '@/app/components/workflow/hooks-store' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import { useStore as useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' +import { useAutoGenerateWebhookUrl } from '../../hooks/use-auto-generate-webhook-url' +import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft' const getTriggerPluginNodeData = ( triggerConfig: TriggerDefaultValue, diff --git a/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts index a615d551b42..27b0e224e80 100644 --- a/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/start/__tests__/use-config.spec.ts @@ -17,18 +17,23 @@ vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => mockUseNodesReadOnly(), - useWorkflow: () => mockUseWorkflow(), - useIsChatMode: () => mockUseIsChatMode(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => mockUseNodesReadOnly(), + useWorkflow: () => mockUseWorkflow(), + useIsChatMode: () => mockUseIsChatMode(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, default: (...args: unknown[]) => mockUseNodeCrud(...args), })) -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../../hooks/use-inspect-vars-crud', () => ({ __esModule: true, default: (...args: unknown[]) => mockUseInspectVarsCrud(...args), })) diff --git a/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts index 938a79a4226..b3f3d783c56 100644 --- a/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/start/__tests__/use-single-run-form-params.spec.ts @@ -11,9 +11,14 @@ vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useIsChatMode: () => mockUseIsChatMode(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useIsChatMode: () => mockUseIsChatMode(), + } +}) const createPayload = (overrides: Partial = {}): StartNodeType => ({ title: 'Start', diff --git a/web/app/components/workflow/nodes/start/use-config.ts b/web/app/components/workflow/nodes/start/use-config.ts index 7dbbd012a86..1abbc48db7d 100644 --- a/web/app/components/workflow/nodes/start/use-config.ts +++ b/web/app/components/workflow/nodes/start/use-config.ts @@ -5,11 +5,11 @@ import { useBoolean } from 'ahooks' import { produce } from 'immer' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useIsChatMode, useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { ChangeType } from '@/app/components/workflow/types' import { hasDuplicateStr } from '@/utils/var' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { useIsChatMode, useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' const useConfig = (id: string, payload: StartNodeType) => { const { t } = useTranslation() diff --git a/web/app/components/workflow/nodes/start/use-single-run-form-params.ts b/web/app/components/workflow/nodes/start/use-single-run-form-params.ts index 0bbef15ddef..5b94fec5a8f 100644 --- a/web/app/components/workflow/nodes/start/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/start/use-single-run-form-params.ts @@ -4,7 +4,7 @@ import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/c import type { InputVar, ValueSelector, Variable } from '@/app/components/workflow/types' import { useTranslation } from 'react-i18next' import { InputVarType } from '@/app/components/workflow/types' -import { useIsChatMode } from '../../hooks' +import { useIsChatMode } from '../../hooks/use-workflow' type Params = { id: string diff --git a/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts index a1cce3cd890..ab1bc9db52c 100644 --- a/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/template-transform/__tests__/use-config.spec.ts @@ -1,17 +1,22 @@ import type { TemplateTransformNodeType } from '../types' import type { Variable } from '@/app/components/workflow/types' import { renderHook, waitFor } from '@testing-library/react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useStore } from '@/app/components/workflow/store' import { BlockEnum, VarType } from '@/app/components/workflow/types' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import useVarList from '../../_base/hooks/use-var-list' import useConfig from '../use-config' -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + } +}) vi.mock('@/app/components/workflow/store', () => ({ useStore: vi.fn(), diff --git a/web/app/components/workflow/nodes/template-transform/use-config.ts b/web/app/components/workflow/nodes/template-transform/use-config.ts index dd227abf76d..2b8c572fc93 100644 --- a/web/app/components/workflow/nodes/template-transform/use-config.ts +++ b/web/app/components/workflow/nodes/template-transform/use-config.ts @@ -2,9 +2,9 @@ import type { Var, Variable } from '../../types' import type { TemplateTransformNodeType } from './types' import { produce } from 'immer' import { useCallback, useEffect, useRef } from 'react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { useStore } from '../../store' import { VarType } from '../../types' import useVarList from '../_base/hooks/use-var-list' diff --git a/web/app/components/workflow/nodes/tool/__tests__/node.spec.tsx b/web/app/components/workflow/nodes/tool/__tests__/node.spec.tsx index 6b411922922..896b72c9a66 100644 --- a/web/app/components/workflow/nodes/tool/__tests__/node.spec.tsx +++ b/web/app/components/workflow/nodes/tool/__tests__/node.spec.tsx @@ -7,7 +7,7 @@ import Node from '../node' const mockUseNodePluginInstallation = vi.hoisted(() => vi.fn()) const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({ +vi.mock('../../../hooks/use-node-plugin-installation', () => ({ useNodePluginInstallation: mockUseNodePluginInstallation, })) diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx index 6a83496c95b..7b26283adc0 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-config.spec.tsx @@ -15,9 +15,14 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useLanguage: () => 'en_US', })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: false }), -})) +vi.mock('../../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: false }), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts index 4621199c74e..bbf936925ee 100644 --- a/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts +++ b/web/app/components/workflow/nodes/tool/hooks/__tests__/use-single-run-form-params.spec.ts @@ -10,9 +10,14 @@ const mockUseToolIcon = vi.hoisted(() => vi.fn()) const mockUseNodeCrud = vi.hoisted(() => vi.fn()) const mockFormatToTracingNodeList = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useToolIcon: (...args: unknown[]) => mockUseToolIcon(...args), -})) +vi.mock('../../../../hooks/use-tool-icon', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useToolIcon: (...args: unknown[]) => mockUseToolIcon(...args), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/tool/hooks/use-config.ts b/web/app/components/workflow/nodes/tool/hooks/use-config.ts index c3657210a22..ebc9660c626 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-config.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-config.ts @@ -12,11 +12,11 @@ import { getConfiguredValue, toolParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useWorkflowStore } from '@/app/components/workflow/store' import { updateBuiltInToolCredential } from '@/service/tools' import { useInvalidToolsByType } from '@/service/use-tools' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import { isToolAuthorizationRequired } from '../auth' import { normalizeJsonSchemaType } from '../output-schema-utils' import useCurrentToolCollection from './use-current-tool-collection' diff --git a/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts b/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts index 14d0111131d..c55e2872272 100644 --- a/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts +++ b/web/app/components/workflow/nodes/tool/hooks/use-single-run-form-params.ts @@ -6,9 +6,9 @@ import type { NodeTracing } from '@/types/workflow' import { produce } from 'immer' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useToolIcon } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log' +import { useToolIcon } from '../../../hooks/use-tool-icon' import { VarType } from '../types' type Params = { diff --git a/web/app/components/workflow/nodes/tool/node.tsx b/web/app/components/workflow/nodes/tool/node.tsx index b41b1a0f940..3d006020213 100644 --- a/web/app/components/workflow/nodes/tool/node.tsx +++ b/web/app/components/workflow/nodes/tool/node.tsx @@ -4,8 +4,8 @@ import type { NodeProps } from '@/app/components/workflow/types' import * as React from 'react' import { useTranslation } from 'react-i18next' import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '../../hooks/use-node-plugin-installation' import { isToolAuthorizationRequired } from './auth' import useCurrentToolCollection from './hooks/use-current-tool-collection' diff --git a/web/app/components/workflow/nodes/trigger-plugin/node.tsx b/web/app/components/workflow/nodes/trigger-plugin/node.tsx index d87b0f46453..d6c0fb01787 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/node.tsx +++ b/web/app/components/workflow/nodes/trigger-plugin/node.tsx @@ -5,8 +5,8 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status' -import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation' import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' +import { useNodePluginInstallation } from '../../hooks/use-node-plugin-installation' import useConfig from './use-config' const formatConfigValue = (rawValue: any): string => { diff --git a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts index 5627e7b1b30..1e24c6e2921 100644 --- a/web/app/components/workflow/nodes/trigger-plugin/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-plugin/use-config.ts @@ -8,9 +8,9 @@ import { getConfiguredValue, toolParametersToFormSchemas, } from '@/app/components/tools/utils/to-form-schema' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useAllTriggerPlugins, useTriggerSubscriptions } from '@/service/use-triggers' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { VarKindType } from '../_base/types' const normalizeEventParameters = ( diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts index 783b4276f7d..5498c8c3488 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts @@ -1,16 +1,21 @@ import type { ScheduleTriggerNodeType } from '../types' import { renderHook } from '@testing-library/react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' +import { useNodesReadOnly } from '../../../hooks/use-workflow' import { BlockEnum } from '../../../types' import useConfig from '../use-config' const mockConsoleStateReader = vi.hoisted(() => vi.fn()) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: vi.fn(), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: vi.fn(), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ __esModule: true, diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts index dbd8a853001..accfa526a11 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts @@ -1,9 +1,9 @@ import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types' import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { useNodesReadOnly } from '../../hooks/use-workflow' import { getDefaultVisualConfig } from './constants' const useConfig = (id: string, payload: ScheduleTriggerNodeType) => { diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx index ec7f7288ca5..3cf1530b045 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/use-config.spec.tsx @@ -28,13 +28,18 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => mockUseNodesReadOnly(), - useWorkflow: () => ({ - isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args), - removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args), - }), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => mockUseNodesReadOnly(), + useWorkflow: () => ({ + isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args), + removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args), + }), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ ...createNodeCrudModuleMock(mockSetInputs), diff --git a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts index 10ef38ecd1e..a87e165a5d6 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-webhook/use-config.ts @@ -4,9 +4,9 @@ import { toast } from '@langgenius/dify-ui/toast' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' -import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { fetchWebhookUrl } from '@/service/apps' +import { useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { updateContentType, updateMethod, diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts index a635389c0fc..2a2cd77df0f 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/hooks.spec.ts @@ -19,12 +19,33 @@ vi.mock('reactflow', () => ({ useNodes: () => mockUseNodes(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodeDataUpdate: () => mockUseNodeDataUpdate(), - useWorkflow: () => mockUseWorkflow(), - useWorkflowVariables: () => mockUseWorkflowVariables(), - useIsChatMode: () => mockUseIsChatMode(), -})) +vi.mock('../../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodeDataUpdate: () => mockUseNodeDataUpdate(), + } +}) + +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflow: () => mockUseWorkflow(), + useIsChatMode: () => mockUseIsChatMode(), + } +}) + +vi.mock('../../../hooks/use-workflow-variables', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowVariables: () => mockUseWorkflowVariables(), + } +}) vi.mock('@/app/components/workflow/store', () => ({ useWorkflowStore: () => mockUseWorkflowStore(), diff --git a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx index 467f8040036..c795ba8bcaf 100644 --- a/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx +++ b/web/app/components/workflow/nodes/variable-assigner/__tests__/use-config.spec.tsx @@ -40,20 +40,25 @@ vi.mock('ahooks', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ nodesReadOnly: false }), - useWorkflow: () => ({ - handleOutVarRenameChange: (...args: unknown[]) => mockHandleOutVarRenameChange(...args), - isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args), - removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args), - }), -})) +vi.mock('../../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ nodesReadOnly: false }), + useWorkflow: () => ({ + handleOutVarRenameChange: (...args: unknown[]) => mockHandleOutVarRenameChange(...args), + isVarUsedInNodes: (...args: unknown[]) => mockIsVarUsedInNodes(...args), + removeUsedVarInNodes: (...args: unknown[]) => mockRemoveUsedVarInNodes(...args), + }), + } +}) vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ ...createNodeCrudModuleMock(mockSetInputs), })) -vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud', () => ({ +vi.mock('../../../hooks/use-inspect-vars-crud', () => ({ __esModule: true, default: () => ({ deleteNodeInspectorVars: (...args: unknown[]) => mockDeleteNodeInspectorVars(...args), diff --git a/web/app/components/workflow/nodes/variable-assigner/hooks.ts b/web/app/components/workflow/nodes/variable-assigner/hooks.ts index a4dde1bf183..9ebcee4d8a7 100644 --- a/web/app/components/workflow/nodes/variable-assigner/hooks.ts +++ b/web/app/components/workflow/nodes/variable-assigner/hooks.ts @@ -5,8 +5,10 @@ import { produce } from 'immer' import { useCallback } from 'react' import { useNodes, useStoreApi } from 'reactflow' import { FlowType } from '@/types/common' -import { useIsChatMode, useNodeDataUpdate, useWorkflow, useWorkflowVariables } from '../../hooks' import { useHooksStore } from '../../hooks-store/store' +import { useNodeDataUpdate } from '../../hooks/use-node-data-update' +import { useIsChatMode, useWorkflow } from '../../hooks/use-workflow' +import { useWorkflowVariables } from '../../hooks/use-workflow-variables' import { useWorkflowStore } from '../../store' import { filterSnippetSystemVars, isSnippetCanvas } from '../_base/hooks/snippet-input-field-vars' diff --git a/web/app/components/workflow/nodes/variable-assigner/use-config.ts b/web/app/components/workflow/nodes/variable-assigner/use-config.ts index e535d9c453d..749c477aa0f 100644 --- a/web/app/components/workflow/nodes/variable-assigner/use-config.ts +++ b/web/app/components/workflow/nodes/variable-assigner/use-config.ts @@ -2,9 +2,9 @@ import type { ValueSelector } from '../../types' import type { VarGroupItem, VariableAssignerNodeType } from './types' import { useBoolean, useDebounceFn } from 'ahooks' import { useCallback, useRef, useState } from 'react' -import { useNodesReadOnly, useWorkflow } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' +import { useNodesReadOnly, useWorkflow } from '../../hooks/use-workflow' import { useGetAvailableVars } from './hooks' import { addGroup, diff --git a/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx b/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx index f31e5502849..478a93ab9d6 100644 --- a/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/note-node/__tests__/hooks.spec.tsx @@ -5,10 +5,13 @@ import { useNote } from '../hooks' const mockHandleNodeDataUpdateWithSyncDraft = vi.hoisted(() => vi.fn()) const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-node-data-update', () => ({ useNodeDataUpdate: () => ({ handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, }), +})) + +vi.mock('../../hooks/use-workflow-history', () => ({ useWorkflowHistory: () => ({ saveStateToHistory: mockSaveStateToHistory, }), diff --git a/web/app/components/workflow/note-node/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/__tests__/index.spec.tsx index 9f501b63435..083bbeaa863 100644 --- a/web/app/components/workflow/note-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/__tests__/index.spec.tsx @@ -24,13 +24,20 @@ const { mockHandleThemeChange: vi.fn(), })) -vi.mock('../../hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../hooks/use-node-data-update', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useNodeDataUpdate: () => ({ handleNodeDataUpdateWithSyncDraft: mockHandleNodeDataUpdateWithSyncDraft, }), + } +}) + +vi.mock('../../hooks/use-nodes-interactions', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, useNodesInteractions: () => ({ handleNodesCopy: mockHandleNodesCopy, handleNodesDuplicate: mockHandleNodesDuplicate, diff --git a/web/app/components/workflow/note-node/hooks.ts b/web/app/components/workflow/note-node/hooks.ts index 602a57ef5c0..7daa2ddbd88 100644 --- a/web/app/components/workflow/note-node/hooks.ts +++ b/web/app/components/workflow/note-node/hooks.ts @@ -1,7 +1,8 @@ import type { EditorState } from 'lexical' import type { NoteTheme } from './types' import { useCallback } from 'react' -import { useNodeDataUpdate, useWorkflowHistory, WorkflowHistoryEvent } from '../hooks' +import { useNodeDataUpdate } from '../hooks/use-node-data-update' +import { useWorkflowHistory, WorkflowHistoryEvent } from '../hooks/use-workflow-history' import { useSetWorkflowNoteShowAuthor } from '../persistence/local-storage-options' export const useNote = (id: string) => { diff --git a/web/app/components/workflow/note-node/index.tsx b/web/app/components/workflow/note-node/index.tsx index 411a182a980..31a8c2b3d22 100644 --- a/web/app/components/workflow/note-node/index.tsx +++ b/web/app/components/workflow/note-node/index.tsx @@ -4,7 +4,8 @@ import { cn } from '@langgenius/dify-ui/cn' import { useClickAway } from 'ahooks' import { memo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useNodeDataUpdate, useNodesInteractions } from '../hooks' +import { useNodeDataUpdate } from '../hooks/use-node-data-update' +import { useNodesInteractions } from '../hooks/use-nodes-interactions' import NodeResizer from '../nodes/_base/components/node-resizer' import { useStore } from '../store/workflow' import { THEME_MAP } from './constants' diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx index fe9f03bda31..29b08a4c0b9 100644 --- a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx @@ -88,17 +88,26 @@ vi.mock('@/app/components/workflow/block-selector', () => ({ }, })) -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-available-blocks', () => ({ useAvailableBlocks: () => ({ availableNextBlocks: mockAvailableNextBlocks, }), +})) + +vi.mock('../../hooks/use-workflow', () => ({ useIsChatMode: () => mockIsChatMode, - useNodesMetaData: () => ({ - nodesMap: mockNodesMetaDataMap, - }), useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly, }), +})) + +vi.mock('../../hooks/use-nodes-meta-data', () => ({ + useNodesMetaData: () => ({ + nodesMap: mockNodesMetaDataMap, + }), +})) + +vi.mock('../../hooks/use-panel-interactions', () => ({ usePanelInteractions: () => ({ handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, }), diff --git a/web/app/components/workflow/operator/__tests__/control.spec.tsx b/web/app/components/workflow/operator/__tests__/control.spec.tsx index 8a92d57e6ee..1b6c7bc272a 100644 --- a/web/app/components/workflow/operator/__tests__/control.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/control.spec.tsx @@ -27,11 +27,14 @@ let mockCanUseCommentMode = true let mockIsCommentModeAvailable = true let mockStoreState: WorkflowStoreState -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-workflow', () => ({ useNodesReadOnly: () => ({ nodesReadOnly: mockNodesReadOnly, getNodesReadOnly: () => mockNodesReadOnly, }), +})) + +vi.mock('../../hooks/use-workflow-panel-interactions', () => ({ useWorkflowMoveMode: () => ({ handleModePointer: mockHandleModePointer, handleModeHand: mockHandleModeHand, @@ -39,6 +42,9 @@ vi.mock('../../hooks', () => ({ isCommentModeAvailable: mockIsCommentModeAvailable, canUseCommentMode: mockCanUseCommentMode, }), +})) + +vi.mock('../../hooks/use-workflow-organize', () => ({ useWorkflowOrganize: () => ({ handleLayout: mockHandleLayout, }), diff --git a/web/app/components/workflow/operator/__tests__/index.spec.tsx b/web/app/components/workflow/operator/__tests__/index.spec.tsx index bb045b63712..adb109b4cd8 100644 --- a/web/app/components/workflow/operator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/index.spec.tsx @@ -7,13 +7,20 @@ import Operator from '../index' const mockEmit = vi.fn() const mockDeleteAllInspectorVars = vi.fn() -vi.mock('../../hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../hooks/use-nodes-sync-draft', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: vi.fn(), }), + } +}) + +vi.mock('../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, useWorkflowReadOnly: () => ({ workflowReadOnly: false, getWorkflowReadOnly: () => false, diff --git a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx index a31c6afd4a8..48e40bd4c71 100644 --- a/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/more-actions.spec.tsx @@ -135,11 +135,16 @@ vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: typeof mockWorkflowState) => unknown) => selector(mockWorkflowState), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ - getNodesReadOnly: mockGetNodesReadOnly, - }), -})) +vi.mock('../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ + getNodesReadOnly: mockGetNodesReadOnly, + }), + } +}) vi.mock('@/utils/download', () => ({ downloadUrl: (...args: unknown[]) => mockDownloadUrl(...args), diff --git a/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx index c0f2f112616..5c77e7e091b 100644 --- a/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx +++ b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx @@ -37,15 +37,28 @@ vi.mock('reactflow', () => ({ useViewport: () => mockViewport, })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesSyncDraft: () => ({ - handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, - }), - useWorkflowReadOnly: () => ({ - workflowReadOnly, - getWorkflowReadOnly: () => workflowReadOnly, - }), -})) +vi.mock('../../hooks/use-nodes-sync-draft', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }), + } +}) + +vi.mock('../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowReadOnly: () => ({ + workflowReadOnly, + getWorkflowReadOnly: () => workflowReadOnly, + }), + } +}) vi.mock('../tip-popup', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}, diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index 2f589b01cda..171f095b865 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -9,14 +9,11 @@ import { useStoreApi } from 'reactflow' import BlockSelector from '@/app/components/workflow/block-selector' import { BlockEnum } from '@/app/components/workflow/types' import { FlowType } from '@/types/common' -import { - useAvailableBlocks, - useIsChatMode, - useNodesMetaData, - useNodesReadOnly, - usePanelInteractions, -} from '../hooks' import { useHooksStore } from '../hooks-store' +import { useAvailableBlocks } from '../hooks/use-available-blocks' +import { useNodesMetaData } from '../hooks/use-nodes-meta-data' +import { usePanelInteractions } from '../hooks/use-panel-interactions' +import { useIsChatMode, useNodesReadOnly } from '../hooks/use-workflow' import { useWorkflowStore } from '../store' import { generateNewNode, diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx index 1fdbaf976ab..757f841847b 100644 --- a/web/app/components/workflow/operator/control.tsx +++ b/web/app/components/workflow/operator/control.tsx @@ -4,7 +4,9 @@ import { cn } from '@langgenius/dify-ui/cn' import { memo } from 'react' import { useTranslation } from 'react-i18next' import Divider from '../../base/divider' -import { useNodesReadOnly, useWorkflowMoveMode, useWorkflowOrganize } from '../hooks' +import { useNodesReadOnly } from '../hooks/use-workflow' +import { useWorkflowOrganize } from '../hooks/use-workflow-organize' +import { useWorkflowMoveMode } from '../hooks/use-workflow-panel-interactions' import { useStore } from '../store' import { ControlMode } from '../types' import AddBlock from './add-block' diff --git a/web/app/components/workflow/operator/more-actions.tsx b/web/app/components/workflow/operator/more-actions.tsx index f902fa46d36..2ae242fbbb9 100644 --- a/web/app/components/workflow/operator/more-actions.tsx +++ b/web/app/components/workflow/operator/more-actions.tsx @@ -14,7 +14,7 @@ import { getNodesBounds, useReactFlow } from 'reactflow' import ImagePreview from '@/app/components/base/image-uploader/image-preview' import { useStore } from '@/app/components/workflow/store' import { downloadUrl } from '@/utils/download' -import { useNodesReadOnly } from '../hooks' +import { useNodesReadOnly } from '../hooks/use-workflow' import TipPopup from './tip-popup' function MoreActions() { diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx index 6fa10732f67..c07427714d5 100644 --- a/web/app/components/workflow/operator/zoom-in-out.tsx +++ b/web/app/components/workflow/operator/zoom-in-out.tsx @@ -11,7 +11,8 @@ import { Fragment, memo } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow, useViewport } from 'reactflow' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { useNodesSyncDraft, useWorkflowReadOnly } from '../hooks' +import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft' +import { useWorkflowReadOnly } from '../hooks/use-workflow' import { ShortcutKbd } from '../shortcuts/shortcut-kbd' import TipPopup from './tip-popup' diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index 74564c6eafb..9ea0141a75d 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -8,14 +8,12 @@ import { import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { FlowType } from '@/types/common' -import { - useDSL, - useIsChatMode, - useNodesInteractions, - useWorkflowMoveMode, - useWorkflowStartRun, -} from './hooks' import { useHooksStore } from './hooks-store' +import { useDSL } from './hooks/use-DSL' +import { useNodesInteractions } from './hooks/use-nodes-interactions' +import { useIsChatMode } from './hooks/use-workflow' +import { useWorkflowMoveMode } from './hooks/use-workflow-panel-interactions' +import { useWorkflowStartRun } from './hooks/use-workflow-start-run' import { TEST_RUN_MENU_HOTKEY } from './hotkeys' import { isSnippetCanvas } from './nodes/_base/hooks/snippet-input-field-vars' import AddBlock from './operator/add-block' diff --git a/web/app/components/workflow/panel/__tests__/record.spec.tsx b/web/app/components/workflow/panel/__tests__/record.spec.tsx index 04edb0e2196..37d88ed3e0e 100644 --- a/web/app/components/workflow/panel/__tests__/record.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/record.spec.tsx @@ -11,11 +11,16 @@ const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined -vi.mock('@/app/components/workflow/hooks', () => ({ - useWorkflowUpdate: () => ({ - handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, - }), -})) +vi.mock('../../hooks/use-workflow-update', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useWorkflowUpdate: () => ({ + handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, + }), + } +}) vi.mock('@/app/components/workflow/run', () => ({ default: ({ diff --git a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx index 78ac2309b6f..b13fa7ee6da 100644 --- a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx @@ -30,11 +30,17 @@ vi.mock('@/service/workflow', () => ({ submitHumanInputForm: vi.fn(), })) -vi.mock('@/app/components/workflow/hooks', () => ({ - useWorkflowInteractions: () => ({ - handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, - }), -})) +vi.mock('../../hooks/use-workflow-panel-interactions', async (importOriginal) => { + const actual = + await importOriginal() + + return { + ...actual, + useWorkflowInteractions: () => ({ + handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, + }), + } +}) vi.mock('@/app/components/workflow/run/result-panel', () => ({ default: ({ status, onOpenTracingTab }: { status?: string; onOpenTracingTab?: () => void }) => ( diff --git a/web/app/components/workflow/panel/chat-record/index.tsx b/web/app/components/workflow/panel/chat-record/index.tsx index be892c72db6..0d1d92fc371 100644 --- a/web/app/components/workflow/panel/chat-record/index.tsx +++ b/web/app/components/workflow/panel/chat-record/index.tsx @@ -8,7 +8,7 @@ import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import Loading from '@/app/components/base/loading' import { fetchConversationMessages } from '@/service/debug' -import { useWorkflowRun } from '../../hooks' +import { useWorkflowRun } from '../../hooks/use-workflow-run' import { useStore, useWorkflowStore } from '../../store' import { formatWorkflowRunIdentifier } from '../../utils' import UserInput from './user-input' diff --git a/web/app/components/workflow/panel/chat-variable-panel/index.tsx b/web/app/components/workflow/panel/chat-variable-panel/index.tsx index 599b3159fc6..e22e0198899 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/index.tsx @@ -11,7 +11,6 @@ import { } from '@/app/components/base/icons/src/vender/line/others' import BlockIcon from '@/app/components/workflow/block-icon' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm' import { findUsedVarNodes, @@ -22,6 +21,7 @@ import VariableModalTrigger from '@/app/components/workflow/panel/chat-variable- import { useStore } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' import { updateConversationVariables } from '@/service/workflow' +import { useCollaborativeWorkflow } from '../../hooks/use-collaborative-workflow' import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud' const ChatVariablePanel = () => { diff --git a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx index 3ac8345f50c..fbd7286f182 100644 --- a/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/comments-panel/__tests__/index.spec.tsx @@ -81,7 +81,7 @@ vi.mock('@/app/components/workflow/store', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks/use-workflow-comment', () => ({ +vi.mock('../../../hooks/use-workflow-comment', () => ({ useWorkflowComment: () => ({ comments: commentFixtures, loading: false, diff --git a/web/app/components/workflow/panel/comments-panel/index.tsx b/web/app/components/workflow/panel/comments-panel/index.tsx index 5be4f6eaf57..b237effb736 100644 --- a/web/app/components/workflow/panel/comments-panel/index.tsx +++ b/web/app/components/workflow/panel/comments-panel/index.tsx @@ -13,11 +13,11 @@ import { memo, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { UserAvatarList } from '@/app/components/base/user-avatar-list' -import { useWorkflowComment } from '@/app/components/workflow/hooks/use-workflow-comment' import { useStore } from '@/app/components/workflow/store' import { ControlMode } from '@/app/components/workflow/types' import { userProfileIdAtom } from '@/context/account-state' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import { useWorkflowComment } from '../../hooks/use-workflow-comment' const CommentsPanel = () => { const { t } = useTranslation() diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts index a6dbda9e4a8..870188d243d 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks.spec.ts @@ -45,8 +45,11 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun }), +})) + +vi.mock('../../../hooks/use-set-workflow-vars-with-value', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), })) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts index f680189e937..1809a7f79a1 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts @@ -44,8 +44,11 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../../hooks', () => ({ +vi.mock('../../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun }), +})) + +vi.mock('../../../../hooks/use-set-workflow-vars-with-value', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), })) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts index c594a0a511b..e8215fc0320 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-send.spec.ts @@ -46,8 +46,11 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../../hooks', () => ({ +vi.mock('../../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun }), +})) + +vi.mock('../../../../hooks/use-set-workflow-vars-with-value', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), })) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts index 8a44eb4936f..8027d92b393 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-stop-restart.spec.ts @@ -44,8 +44,11 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../../hooks', () => ({ +vi.mock('../../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun }), +})) + +vi.mock('../../../../hooks/use-set-workflow-vars-with-value', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), })) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts index 567f3d1c3b5..206a33ad511 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/misc.spec.ts @@ -44,8 +44,11 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../../hooks', () => ({ +vi.mock('../../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun }), +})) + +vi.mock('../../../../hooks/use-set-workflow-vars-with-value', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), })) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts index 294ad2e572d..c9016fc15c3 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/opening-statement.spec.ts @@ -44,8 +44,11 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../../hooks', () => ({ +vi.mock('../../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun }), +})) + +vi.mock('../../../../hooks/use-set-workflow-vars-with-value', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), })) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts index 613ee45b5ff..ae8325e9f62 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts @@ -43,8 +43,11 @@ vi.mock('reactflow', () => ({ }), })) -vi.mock('../../../../hooks', () => ({ +vi.mock('../../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRun: mockHandleRun }), +})) + +vi.mock('../../../../hooks/use-set-workflow-vars-with-value', () => ({ useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: mockFetchInspectVars }), })) diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index 00b261eddb3..69c91d2c397 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -23,8 +23,9 @@ import { useInvalidAllLastRun } from '@/service/use-workflow' import { submitHumanInputForm } from '@/service/workflow' import { TransferMethod } from '@/types/app' import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../../constants' -import { useSetWorkflowVarsWithValue, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' +import { useSetWorkflowVarsWithValue } from '../../hooks/use-set-workflow-vars-with-value' +import { useWorkflowRun } from '../../hooks/use-workflow-run' import { useWorkflowStore } from '../../store' import { NodeRunningStatus, WorkflowRunningStatus } from '../../types' diff --git a/web/app/components/workflow/panel/debug-and-preview/index.tsx b/web/app/components/workflow/panel/debug-and-preview/index.tsx index 1226e147b1f..119036618f8 100644 --- a/web/app/components/workflow/panel/debug-and-preview/index.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/index.tsx @@ -9,10 +9,10 @@ import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows' -import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync' -import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync' import { useStore } from '@/app/components/workflow/store' -import { useWorkflowInteractions } from '../../hooks' +import { useEdgesInteractionsWithoutSync } from '../../hooks/use-edges-interactions-without-sync' +import { useNodesInteractionsWithoutSync } from '../../hooks/use-nodes-interactions-without-sync' +import { useWorkflowInteractions } from '../../hooks/use-workflow-panel-interactions' import { useResizePanel } from '../../nodes/_base/hooks/use-resize-panel' import { useSetDebugPreviewPanelWidth } from '../../persistence/local-storage-options' import { BlockEnum } from '../../types' diff --git a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx index ff83dc858b4..4afc4a470c6 100644 --- a/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/env-panel/__tests__/index.spec.tsx @@ -48,7 +48,7 @@ const { >(() => null), })) -vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({ +vi.mock('../../../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft, }), diff --git a/web/app/components/workflow/panel/env-panel/index.tsx b/web/app/components/workflow/panel/env-panel/index.tsx index f3c3bc0d3f3..26872f3d4ce 100644 --- a/web/app/components/workflow/panel/env-panel/index.tsx +++ b/web/app/components/workflow/panel/env-panel/index.tsx @@ -3,8 +3,6 @@ import { cn } from '@langgenius/dify-ui/cn' import { RiCloseLine } from '@remixicon/react' import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' -import { useNodesSyncDraft } from '@/app/components/workflow/hooks/use-nodes-sync-draft' import RemoveEffectVarConfirm from '@/app/components/workflow/nodes/_base/components/remove-effect-var-confirm' import { findUsedVarNodes, @@ -13,6 +11,8 @@ import { import EnvItem from '@/app/components/workflow/panel/env-panel/env-item' import VariableTrigger from '@/app/components/workflow/panel/env-panel/variable-trigger' import { useStore } from '@/app/components/workflow/store' +import { useCollaborativeWorkflow } from '../../hooks/use-collaborative-workflow' +import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft' const HIDDEN_SECRET_VALUE = '[__HIDDEN__]' type DoSyncWorkflowDraft = ReturnType['doSyncWorkflowDraft'] diff --git a/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx index 1b95d76180e..8bd1db550ef 100644 --- a/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ vi.mock('../../../constants', () => ({ isInWorkflowPage: () => mockIsWorkflowPage, })) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-workflow', () => ({ useIsChatMode: () => mockIsChatMode, })) diff --git a/web/app/components/workflow/panel/global-variable-panel/index.tsx b/web/app/components/workflow/panel/global-variable-panel/index.tsx index 736e3ef9093..3d97b8de0ff 100644 --- a/web/app/components/workflow/panel/global-variable-panel/index.tsx +++ b/web/app/components/workflow/panel/global-variable-panel/index.tsx @@ -5,7 +5,7 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' import { useStore } from '@/app/components/workflow/store' import { isInWorkflowPage } from '../../constants' -import { useIsChatMode } from '../../hooks' +import { useIsChatMode } from '../../hooks/use-workflow' import Item from './item' const Panel = () => { diff --git a/web/app/components/workflow/panel/inputs-panel.tsx b/web/app/components/workflow/panel/inputs-panel.tsx index 0aed00193e7..f023f1125b4 100644 --- a/web/app/components/workflow/panel/inputs-panel.tsx +++ b/web/app/components/workflow/panel/inputs-panel.tsx @@ -6,8 +6,8 @@ import { useNodes } from 'reactflow' import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks' import { getProcessedInputs } from '@/app/components/base/chat/chat/utils' import { TransferMethod } from '../../base/text-generation/types' -import { useWorkflowRun } from '../hooks' import { useHooksStore } from '../hooks-store' +import { useWorkflowRun } from '../hooks/use-workflow-run' import FormItem from '../nodes/_base/components/before-run-form/form-item' import { useStore, useWorkflowStore } from '../store' import { BlockEnum, InputVarType, WorkflowRunningStatus } from '../types' diff --git a/web/app/components/workflow/panel/record.tsx b/web/app/components/workflow/panel/record.tsx index 14bc2b2af95..b01ead287b8 100644 --- a/web/app/components/workflow/panel/record.tsx +++ b/web/app/components/workflow/panel/record.tsx @@ -1,7 +1,7 @@ import type { WorkflowRunDetailResponse } from '@/models/log' import { memo, useCallback } from 'react' -import { useWorkflowUpdate } from '../hooks' import { useHooksStore } from '../hooks-store' +import { useWorkflowUpdate } from '../hooks/use-workflow-update' import Run from '../run' import { useStore } from '../store' import { formatWorkflowRunIdentifier } from '../utils' diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx index b004638db36..b339f4c4280 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx @@ -112,9 +112,15 @@ vi.mock('@/service/use-workflow', () => ({ }), })) -vi.mock('../../../hooks', () => ({ +vi.mock('../../../hooks/use-DSL', () => ({ useDSL: () => ({ handleExportDSL: mockHandleExportDSL }), +})) + +vi.mock('../../../hooks/use-workflow-refresh-draft', () => ({ useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }), +})) + +vi.mock('../../../hooks/use-workflow-run', () => ({ useWorkflowRun: () => ({ handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow, handleLoadBackupDraft: mockHandleLoadBackupDraft, diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index ab9528f7086..9cfb4b65ef7 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -21,8 +21,10 @@ import { useUpdateWorkflow, useWorkflowVersionHistory, } from '@/service/use-workflow' -import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' +import { useDSL } from '../../hooks/use-DSL' +import { useWorkflowRefreshDraft } from '../../hooks/use-workflow-refresh-draft' +import { useWorkflowRun } from '../../hooks/use-workflow-run' import { useStore, useWorkflowStore } from '../../store' import { VersionHistoryContextMenuOptions, diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index 00b463a5f36..2d1be46dc99 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next' import ReasoningPanel from '@/app/components/base/chat/chat/answer/reasoning-panel' import Loading from '@/app/components/base/loading' import { submitHumanInputForm } from '@/service/workflow' -import { useWorkflowInteractions } from '../hooks' +import { useWorkflowInteractions } from '../hooks/use-workflow-panel-interactions' import ResultPanel from '../run/result-panel' import ResultText from '../run/result-text' import TracingPanel from '../run/tracing-panel' diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 688cd5aa0d9..b7ad0d07982 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -14,9 +14,11 @@ import { useTranslation } from 'react-i18next' import { useStore as useReactFlowStore } from 'reactflow' import { useCreateSnippetFromSelection } from '@/app/components/snippets/hooks/use-create-snippet-from-selection' import { canCreateAndModifySnippets } from '@/app/components/snippets/utils/permission' -import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow' import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks' +import { useCollaborativeWorkflow } from './hooks/use-collaborative-workflow' +import { useNodesInteractions } from './hooks/use-nodes-interactions' +import { useNodesSyncDraft } from './hooks/use-nodes-sync-draft' +import { useNodesReadOnly } from './hooks/use-workflow' import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history' import { ShortcutKbd } from './shortcuts/shortcut-kbd' import { useStore, useWorkflowStore } from './store' diff --git a/web/app/components/workflow/simple-node/__tests__/index.spec.tsx b/web/app/components/workflow/simple-node/__tests__/index.spec.tsx index bf0e4034e89..50490d1ba78 100644 --- a/web/app/components/workflow/simple-node/__tests__/index.spec.tsx +++ b/web/app/components/workflow/simple-node/__tests__/index.spec.tsx @@ -4,11 +4,16 @@ import SimpleNode from '../index' let mockNodesReadOnly = false -vi.mock('@/app/components/workflow/hooks', () => ({ - useNodesReadOnly: () => ({ - nodesReadOnly: mockNodesReadOnly, - }), -})) +vi.mock('../../hooks/use-workflow', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useNodesReadOnly: () => ({ + nodesReadOnly: mockNodesReadOnly, + }), + } +}) vi.mock('@/app/components/workflow/block-icon', () => ({ __esModule: true, diff --git a/web/app/components/workflow/simple-node/index.tsx b/web/app/components/workflow/simple-node/index.tsx index 64f6f44cc9f..9aee353d578 100644 --- a/web/app/components/workflow/simple-node/index.tsx +++ b/web/app/components/workflow/simple-node/index.tsx @@ -9,10 +9,10 @@ import { } from '@remixicon/react' import { memo, useMemo } from 'react' import BlockIcon from '@/app/components/workflow/block-icon' -import { useNodesReadOnly } from '@/app/components/workflow/hooks' import NodeControl from '@/app/components/workflow/nodes/_base/components/node-control' import { NodeTargetHandle } from '@/app/components/workflow/nodes/_base/components/node-handle' import { NodeRunningStatus } from '@/app/components/workflow/types' +import { useNodesReadOnly } from '../hooks/use-workflow' type SimpleNodeProps = NodeProps diff --git a/web/app/components/workflow/store/workflow/node-slice.ts b/web/app/components/workflow/store/workflow/node-slice.ts index c1427e987b4..383a30eb224 100644 --- a/web/app/components/workflow/store/workflow/node-slice.ts +++ b/web/app/components/workflow/store/workflow/node-slice.ts @@ -1,5 +1,5 @@ import type { StateCreator } from 'zustand' -import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist' +import type { ChecklistItem } from '../../hooks/use-checklist' import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types' import type { Node } from '@/app/components/workflow/types' import type { NodeTracing } from '@/types/workflow' diff --git a/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx index 642bb80e6c9..c954badaf73 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/group.spec.tsx @@ -6,8 +6,8 @@ import Group from '../group' const mockUseToolIcon = vi.fn(() => '') -vi.mock('../../hooks', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../hooks/use-tool-icon', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useToolIcon: () => mockUseToolIcon(), diff --git a/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx index cde78a099ee..dccdf240519 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/listening.spec.tsx @@ -10,7 +10,7 @@ vi.mock('copy-to-clipboard', () => ({ default: (...args: unknown[]) => mockCopy(...args), })) -vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({ +vi.mock('../../hooks/use-tool-icon', () => ({ useGetToolIcon: () => () => 'tool-icon', })) diff --git a/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx b/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx index 1483effddc6..47c1d29e915 100644 --- a/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/variable-inspect/__tests__/panel.spec.tsx @@ -57,14 +57,14 @@ vi.mock('../../hooks/use-nodes-interactions', () => ({ }), })) -vi.mock('../../hooks', () => ({ +vi.mock('../../hooks/use-nodes-interactions', () => ({ useNodesInteractions: () => ({ handleNodeSelect: mockHandleNodeSelect, }), - useToolIcon: () => '', })) -vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({ +vi.mock('../../hooks/use-tool-icon', () => ({ + useToolIcon: () => '', useGetToolIcon: () => () => '', })) diff --git a/web/app/components/workflow/variable-inspect/group.tsx b/web/app/components/workflow/variable-inspect/group.tsx index 094171c58d7..39611a977c8 100644 --- a/web/app/components/workflow/variable-inspect/group.tsx +++ b/web/app/components/workflow/variable-inspect/group.tsx @@ -16,7 +16,7 @@ import ActionButton from '@/app/components/base/action-button' import BlockIcon from '@/app/components/workflow/block-icon' import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' import { VarInInspectType } from '@/types/workflow' -import { useToolIcon } from '../hooks' +import { useToolIcon } from '../hooks/use-tool-icon' type Props = Readonly<{ nodeData?: NodeWithVar diff --git a/web/app/components/workflow/variable-inspect/listening.tsx b/web/app/components/workflow/variable-inspect/listening.tsx index 3f1caaf1dbb..f658105a92f 100644 --- a/web/app/components/workflow/variable-inspect/listening.tsx +++ b/web/app/components/workflow/variable-inspect/listening.tsx @@ -11,9 +11,9 @@ import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' import BlockIcon from '@/app/components/workflow/block-icon' -import { useGetToolIcon } from '@/app/components/workflow/hooks/use-tool-icon' import { getNextExecutionTime } from '@/app/components/workflow/nodes/trigger-schedule/utils/execution-time-calculator' import { BlockEnum } from '@/app/components/workflow/types' +import { useGetToolIcon } from '../hooks/use-tool-icon' import { useStore } from '../store' const resolveListeningDescription = ( diff --git a/web/app/components/workflow/variable-inspect/right.tsx b/web/app/components/workflow/variable-inspect/right.tsx index 2e07a1cc910..cce7a711209 100644 --- a/web/app/components/workflow/variable-inspect/right.tsx +++ b/web/app/components/workflow/variable-inspect/right.tsx @@ -25,9 +25,10 @@ import { AppModeEnum } from '@/types/app' import { VarInInspectType } from '@/types/workflow' import GetCodeGeneratorResModal from '../../app/configuration/config/code-generator/get-code-generator-res' import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '../../base/prompt-editor/plugins/update-block' -import { useNodesInteractions, useToolIcon } from '../hooks' import { useHooksStore } from '../hooks-store' import useCurrentVars from '../hooks/use-inspect-vars-crud' +import { useNodesInteractions } from '../hooks/use-nodes-interactions' +import { useToolIcon } from '../hooks/use-tool-icon' import useNodeCrud from '../nodes/_base/hooks/use-node-crud' import useNodeInfo from '../nodes/_base/hooks/use-node-info' import { CodeLanguage } from '../nodes/code/types' From 75c54bd40b766ddd1039d810117e8e749fd27a20 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 27 Jul 2026 09:23:28 +0800 Subject: [PATCH 011/531] =?UTF-8?q?fix(workflow):=20stop=20dropping=20unsa?= =?UTF-8?q?ved=20edits=20when=20collaboration=20never=20c=E2=80=A6=20(#395?= =?UTF-8?q?79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: hjlarry --- .../__tests__/use-nodes-sync-draft.spec.ts | 38 ++++++++++++++++ .../hooks/use-nodes-sync-draft.ts | 13 +++--- .../__tests__/workflow-edge-events.spec.tsx | 43 ++++++++++++++++++- ...n-manager.socket-and-subscriptions.spec.ts | 17 ++++++++ .../core/collaboration-manager.ts | 9 ++++ web/app/components/workflow/index.tsx | 6 +++ 6 files changed, 117 insertions(+), 9 deletions(-) diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index 27a475f093e..a0ce5c1d2e1 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -14,6 +14,7 @@ const mockCollaborationGetIsLeader = vi.fn() const mockCollaborationRequestWorkflowSync = vi.fn() const mockCollaborationCanPersistLocalGraph = vi.fn() const mockCollaborationCanFlushGraphOnPageClose = vi.fn() +const mockCollaborationCanUseLocalDraftFallback = vi.fn() let isCollaborationEnabled = false let reactFlowState: { @@ -72,6 +73,8 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () canPersistLocalGraph: (...args: unknown[]) => mockCollaborationCanPersistLocalGraph(...args), canFlushGraphOnPageClose: (...args: unknown[]) => mockCollaborationCanFlushGraphOnPageClose(...args), + canUseLocalDraftFallback: (...args: unknown[]) => + mockCollaborationCanUseLocalDraftFallback(...args), }, })) @@ -139,6 +142,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => mockCollaborationGetIsLeader.mockReturnValue(true) mockCollaborationCanPersistLocalGraph.mockReturnValue(true) mockCollaborationCanFlushGraphOnPageClose.mockReturnValue(true) + mockCollaborationCanUseLocalDraftFallback.mockReturnValue(false) mockCollaborationRequestWorkflowSync.mockResolvedValue({ hash: 'remote-hash', updatedAt: 2, @@ -610,6 +614,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => isCollaborationEnabled = true mockCollaborationIsConnected.mockReturnValue(true) mockCollaborationGetIsLeader.mockReturnValue(false) + mockCollaborationCanFlushGraphOnPageClose.mockReturnValue(false) const { result } = renderUseNodesSyncDraft() @@ -634,4 +639,37 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => expect(mockPostWithKeepalive).toHaveBeenCalledTimes(1) }) + + it('should still flush with keepalive on page close when collaboration is enabled but never connected', () => { + // Without a connection there is no leader election, so the collaborative flush guard can never + // be satisfied. Skipping the save here would silently drop the edits made before leaving. + isCollaborationEnabled = true + mockCollaborationIsConnected.mockReturnValue(false) + mockCollaborationGetIsLeader.mockReturnValue(false) + mockCollaborationCanFlushGraphOnPageClose.mockReturnValue(false) + mockCollaborationCanUseLocalDraftFallback.mockReturnValue(true) + + const { result } = renderUseNodesSyncDraft() + + act(() => { + result.current.syncWorkflowDraftWhenPageClose() + }) + + expect(mockPostWithKeepalive).toHaveBeenCalledTimes(1) + }) + + it('should not flush an untrusted graph after an established collaboration disconnects', () => { + isCollaborationEnabled = true + mockCollaborationIsConnected.mockReturnValue(false) + mockCollaborationCanFlushGraphOnPageClose.mockReturnValue(false) + mockCollaborationCanUseLocalDraftFallback.mockReturnValue(false) + + const { result } = renderUseNodesSyncDraft() + + act(() => { + result.current.syncWorkflowDraftWhenPageClose() + }) + + expect(mockPostWithKeepalive).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 028907cba4a..df0a623188a 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -129,14 +129,11 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { const syncWorkflowDraftWhenPageClose = useCallback(() => { if (getNodesReadOnly()) return - if (isCollaborationEnabled && !collaborationManager.canFlushGraphOnPageClose()) return - - const isFollower = - isCollaborationEnabled && - collaborationManager.isConnected() && - !collaborationManager.getIsLeader() - - if (isFollower) return + const canPersistOnPageClose = + !isCollaborationEnabled || + collaborationManager.canFlushGraphOnPageClose() || + collaborationManager.canUseLocalDraftFallback() + if (!canPersistOnPageClose) return const postParams = getPostParams() diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index ac8e4c9ef29..05403cf4889 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -26,6 +26,8 @@ const reactFlowBridge = vi.hoisted(() => ({ const collaborationBridge = vi.hoisted(() => ({ canFlushGraphOnPageClose: vi.fn(), + canUseLocalDraftFallback: vi.fn(), + isConnected: vi.fn(), graphImportHandler: null as null | ((payload: { nodes: Node[]; edges: Edge[] }) => void), historyActionHandler: null as null | ((payload: unknown) => void), restoreIntentHandler: null as @@ -83,6 +85,7 @@ const workflowHookMocks = vi.hoisted(() => ({ handleSelectionContextMenu: vi.fn(), handlePaneContextMenu: vi.fn(), handleSyncWorkflowDraft: vi.fn(), + syncWorkflowDraftWhenPageClose: vi.fn(), fetchInspectVars: vi.fn(), isValidConnection: vi.fn(), useShortcuts: vi.fn(), @@ -199,6 +202,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ vi.mock('../collaboration/core/collaboration-manager', () => ({ collaborationManager: { canFlushGraphOnPageClose: collaborationBridge.canFlushGraphOnPageClose, + canUseLocalDraftFallback: collaborationBridge.canUseLocalDraftFallback, + isConnected: collaborationBridge.isConnected, onGraphImport: (handler: (payload: { nodes: Node[]; edges: Edge[] }) => void) => { collaborationBridge.graphImportHandler = handler return vi.fn() @@ -421,7 +426,7 @@ vi.mock('../hooks/use-workflow', () => ({ vi.mock('../hooks/use-nodes-sync-draft', () => ({ useNodesSyncDraft: () => ({ handleSyncWorkflowDraft: workflowHookMocks.handleSyncWorkflowDraft, - syncWorkflowDraftWhenPageClose: vi.fn(), + syncWorkflowDraftWhenPageClose: workflowHookMocks.syncWorkflowDraftWhenPageClose, }), })) @@ -557,6 +562,8 @@ describe('Workflow edge event wiring', () => { beforeEach(() => { vi.clearAllMocks() collaborationBridge.canFlushGraphOnPageClose.mockReturnValue(true) + collaborationBridge.canUseLocalDraftFallback.mockReturnValue(false) + collaborationBridge.isConnected.mockReturnValue(true) eventEmitterState.subscription = null reactFlowBridge.store = null collaborationBridge.graphImportHandler = null @@ -711,6 +718,40 @@ describe('Workflow edge event wiring', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) + it('should still save on unmount when collaboration is enabled but never connected', () => { + // No connection means no leader election, so the collaborative flush guard can never be + // satisfied. Skipping the save here would silently discard unsaved edits. + collaborationBridge.isConnected.mockReturnValue(false) + collaborationBridge.canFlushGraphOnPageClose.mockReturnValue(false) + collaborationBridge.canUseLocalDraftFallback.mockReturnValue(true) + + const { unmount } = renderSubject({ + initialStoreState: { isWorkflowDataLoaded: true }, + isCollaborationEnabled: true, + }) + + unmount() + + expect(workflowHookMocks.syncWorkflowDraftWhenPageClose).toHaveBeenCalledTimes(1) + expect(workflowHookMocks.handleSyncWorkflowDraft).not.toHaveBeenCalled() + }) + + it('should skip the unmount save after an established collaboration disconnects', () => { + collaborationBridge.isConnected.mockReturnValue(false) + collaborationBridge.canFlushGraphOnPageClose.mockReturnValue(false) + collaborationBridge.canUseLocalDraftFallback.mockReturnValue(false) + + const { unmount } = renderSubject({ + initialStoreState: { isWorkflowDataLoaded: true }, + isCollaborationEnabled: true, + }) + + unmount() + + expect(workflowHookMocks.syncWorkflowDraftWhenPageClose).not.toHaveBeenCalled() + expect(workflowHookMocks.handleSyncWorkflowDraft).not.toHaveBeenCalled() + }) + it('should render confirm description and clear showConfirm when cancelled', async () => { const onConfirm = vi.fn() const { store } = renderSubject({ diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts index 6151329104d..4e3fa225dc9 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts @@ -161,6 +161,23 @@ describe('CollaborationManager socket and subscription behavior', () => { vi.clearAllMocks() }) + it('allows local draft fallback only before the first collaboration connection', () => { + const { manager, internals } = setupManagerWithDoc() + const socket = createMockSocket('socket-fallback') + + internals.currentAppId = 'app-fallback' + vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false) + internals.setupSocketEventListeners(socket as unknown as Socket) + + expect(manager.canUseLocalDraftFallback()).toBe(true) + + socket.trigger('connect') + expect(manager.canUseLocalDraftFallback()).toBe(false) + + socket.trigger('disconnect', 'transport close') + expect(manager.canUseLocalDraftFallback()).toBe(false) + }) + it('emits cursor/sync/workflow events via collaboration_event when connected', async () => { const { manager, internals } = setupManagerWithDoc() const socket = createMockSocket('socket-connected') diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 0ecf487cf00..5b2c45a20af 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -179,6 +179,7 @@ export class CollaborationManager { private graphViewSequence = 0 private visibilityListenerAttached = false private crdtTrusted = false + private hasEstablishedConnection = false private rebuildCrdtOnNextConnect = false private reconnectedWithFreshDoc = false private awaitingSnapshotImport = false @@ -635,11 +636,13 @@ export class CollaborationManager { // Only disconnect if switching to a different app if (this.currentAppId && this.currentAppId !== appId) this.forceDisconnect() + this.hasEstablishedConnection = false this.currentAppId = appId // Only set store if provided if (reactFlowStore) this.reactFlowStore = reactFlowStore const socket = webSocketClient.connect(appId) + this.hasEstablishedConnection = socket.connected // Setup event listeners BEFORE any other operations this.setupSocketEventListeners(socket) @@ -725,6 +728,11 @@ export class CollaborationManager { return this.currentAppId ? webSocketClient.isConnected(this.currentAppId) : false } + canUseLocalDraftFallback(): boolean { + // A graph from a previously connected session must recover through collaboration before saving. + return !this.isConnected() && !this.hasEstablishedConnection + } + getNodes(): Node[] { if (!this.nodesMap) return [] return Array.from(this.nodesMap.keys()).map((id) => this.exportNode(id as string)) @@ -1838,6 +1846,7 @@ export class CollaborationManager { }) socket.on('connect', () => { + this.hasEstablishedConnection = true if (this.rebuildCrdtOnNextConnect) { this.initializeCrdt(socket) this.rebuildCrdtOnNextConnect = false diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 30fb9578b13..7c325a8237f 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -373,6 +373,12 @@ export const Workflow: FC = memo( const syncWorkflowDraftOnUnmount = useEffectEvent(() => { if (!workflowStore.getState().isWorkflowDataLoaded) return + + if (isCollaborationEnabled && collaborationManager.canUseLocalDraftFallback()) { + syncWorkflowDraftWhenPageClose() + return + } + if (isCollaborationEnabled && !collaborationManager.canFlushGraphOnPageClose()) return handleSyncWorkflowDraft(true, true, { From e5496e04c57875be296c669236dad37ee733b83e Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 10:53:52 +0900 Subject: [PATCH 012/531] test: use sqlite3 session in test_snippet (#38691) --- api/tests/unit_tests/models/test_snippet.py | 87 ++++++++++++++------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/api/tests/unit_tests/models/test_snippet.py b/api/tests/unit_tests/models/test_snippet.py index 17f7cb3c9d4..12500bb2d0c 100644 --- a/api/tests/unit_tests/models/test_snippet.py +++ b/api/tests/unit_tests/models/test_snippet.py @@ -1,10 +1,31 @@ +"""Snippet model properties backed by the shared SQLite test session.""" + import json -from types import SimpleNamespace -from unittest.mock import Mock import pytest +from sqlalchemy.orm import Session +from models import snippet as snippet_module +from models.account import Account +from models.enums import TagType +from models.model import Tag, TagBinding from models.snippet import CustomizedSnippet +from models.workflow import Workflow, WorkflowType + +TENANT_ID = "11111111-1111-1111-1111-111111111111" +WORKFLOW_ID = "22222222-2222-2222-2222-222222222222" +APP_ID = "33333333-3333-3333-3333-333333333333" +SNIPPET_ID = "44444444-4444-4444-4444-444444444444" +ACCOUNT_1_ID = "55555555-5555-5555-5555-555555555555" +ACCOUNT_2_ID = "55555555-5555-5555-5555-555555555556" +SQLITE_MODELS = (Workflow, Tag, TagBinding, Account) + + +@pytest.fixture +def snippet_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session: + """Expose the shared SQLite session to model properties that use the global Flask session.""" + monkeypatch.setattr(snippet_module.db, "session", sqlite_session) + return sqlite_session def test_graph_dict_returns_empty_without_workflow_id() -> None: @@ -13,20 +34,28 @@ def test_graph_dict_returns_empty_without_workflow_id() -> None: assert snippet.graph_dict == {} -def test_graph_dict_loads_published_workflow_graph(monkeypatch: pytest.MonkeyPatch) -> None: - workflow = SimpleNamespace(graph=json.dumps({"nodes": [{"id": "llm-1"}], "edges": []})) - session = SimpleNamespace(get=Mock(return_value=workflow)) - monkeypatch.setattr("models.snippet.db.session", session) - snippet = CustomizedSnippet(workflow_id="workflow-1") +@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) +def test_graph_dict_loads_published_workflow_graph(snippet_session: Session) -> None: + workflow = Workflow( + tenant_id=TENANT_ID, + app_id=APP_ID, + type=WorkflowType.WORKFLOW, + version="1", + graph=json.dumps({"nodes": [{"id": "llm-1"}], "edges": []}), + _features="{}", + created_by=ACCOUNT_1_ID, + ) + workflow.id = WORKFLOW_ID + snippet_session.add(workflow) + snippet_session.commit() + snippet = CustomizedSnippet(workflow_id=WORKFLOW_ID) assert snippet.graph_dict == {"nodes": [{"id": "llm-1"}], "edges": []} - session.get.assert_called_once() -def test_graph_dict_returns_empty_when_workflow_missing(monkeypatch: pytest.MonkeyPatch) -> None: - session = SimpleNamespace(get=Mock(return_value=None)) - monkeypatch.setattr("models.snippet.db.session", session) - snippet = CustomizedSnippet(workflow_id="missing-workflow") +@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) +def test_graph_dict_returns_empty_when_workflow_missing(snippet_session: Session) -> None: + snippet = CustomizedSnippet(workflow_id=WORKFLOW_ID) assert snippet.graph_dict == {} @@ -38,26 +67,30 @@ def test_input_fields_list_parses_json_or_returns_empty() -> None: ] -def test_tags_returns_query_results_or_empty(monkeypatch: pytest.MonkeyPatch) -> None: - tags = [SimpleNamespace(id="tag-1")] - session = SimpleNamespace(scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=tags)))) - monkeypatch.setattr("models.snippet.db.session", session) - snippet = CustomizedSnippet(id="snippet-1", tenant_id="tenant-1") +@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) +def test_tags_returns_query_results_or_empty(snippet_session: Session) -> None: + tag = Tag(tenant_id=TENANT_ID, type=TagType.SNIPPET, name="Reusable", created_by=ACCOUNT_1_ID) + binding = TagBinding(tenant_id=TENANT_ID, tag_id=tag.id, target_id=SNIPPET_ID, created_by=ACCOUNT_1_ID) + snippet_session.add_all((tag, binding)) + snippet_session.commit() + snippet = CustomizedSnippet(id=SNIPPET_ID, tenant_id=TENANT_ID) - assert snippet.tags == tags + assert snippet.tags == [tag] - session.scalars.return_value.all.return_value = None + snippet_session.delete(binding) + snippet_session.commit() assert snippet.tags == [] -def test_account_properties_and_author_name(monkeypatch: pytest.MonkeyPatch) -> None: - account = SimpleNamespace(id="account-1", name="Ada") - updated_account = SimpleNamespace(id="account-2", name="Grace") - session = SimpleNamespace( - get=Mock(side_effect=lambda _model, account_id: account if account_id == "account-1" else updated_account) - ) - monkeypatch.setattr("models.snippet.db.session", session) - snippet = CustomizedSnippet(created_by="account-1", updated_by="account-2") +@pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True) +def test_account_properties_and_author_name(snippet_session: Session) -> None: + account = Account(name="Ada", email="ada@example.com") + account.id = ACCOUNT_1_ID + updated_account = Account(name="Grace", email="grace@example.com") + updated_account.id = ACCOUNT_2_ID + snippet_session.add_all((account, updated_account)) + snippet_session.commit() + snippet = CustomizedSnippet(created_by=ACCOUNT_1_ID, updated_by=ACCOUNT_2_ID) assert snippet.created_by_account is account assert snippet.author_name == "Ada" From b81737cfb334f9abbca65e8ccc9316814b59d6c8 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 11:01:47 +0900 Subject: [PATCH 013/531] test: use sqlite3 session in test_message_service (#38698) --- .../services/test_message_service.py | 1616 ++++++----------- 1 file changed, 605 insertions(+), 1011 deletions(-) diff --git a/api/tests/unit_tests/services/test_message_service.py b/api/tests/unit_tests/services/test_message_service.py index dfdaf7b40e2..afc49c8865c 100644 --- a/api/tests/unit_tests/services/test_message_service.py +++ b/api/tests/unit_tests/services/test_message_service.py @@ -1,13 +1,35 @@ import json +from collections.abc import Iterator from datetime import datetime -from unittest.mock import MagicMock, patch +from decimal import Decimal +from unittest.mock import MagicMock import pytest +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, scoped_session +import models.model as model_module +import services.message_service as service_module +from core.app.entities.app_invoke_entities import InvokeFrom from graphon.model_runtime.entities.model_entities import ModelType -from libs.infinite_scroll_pagination import InfiniteScrollPagination -from models.enums import FeedbackFromSource, FeedbackRating -from models.model import App, AppMode, EndUser, Message +from models.account import Account, AccountStatus +from models.enums import ( + ConversationFromSource, + EndUserType, + FeedbackFromSource, + FeedbackRating, +) +from models.model import ( + App, + AppAnnotationSetting, + AppMode, + AppModelConfig, + Conversation, + EndUser, + Message, + MessageFeedback, +) +from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository from services.errors.message import ( FirstMessageNotExistsError, LastMessageNotExistsError, @@ -16,1247 +38,819 @@ from services.errors.message import ( ) from services.message_service import MessageService, attach_message_extra_contents +SQLITE_MODELS = (Conversation, Message, MessageFeedback, AppModelConfig, AppAnnotationSetting) +pytestmark = [ + pytest.mark.usefixtures("sqlite_session"), + pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True), +] -class TestMessageServiceFactory: - """Factory class for creating test data and mock objects for message service tests.""" + +class _DatabaseBinding: + """Expose the SQLite engine and shared session through the production DB interface.""" + + engine: Engine + session: scoped_session[Session] + + def __init__(self, engine: Engine, session: scoped_session[Session]) -> None: + self.engine = engine + self.session = session + + +class MessageServiceTestDataFactory: + """Create real service inputs and persistent message-domain rows.""" @staticmethod - def create_app_mock( + def create_app( app_id: str = "app-123", - mode: str = AppMode.ADVANCED_CHAT.value, - name: str = "Test App", - ) -> MagicMock: - """Create a mock App object.""" - app = MagicMock(spec=App) - app.id = app_id - app.mode = mode - app.name = name - return app + mode: AppMode = AppMode.ADVANCED_CHAT, + tenant_id: str = "tenant-123", + ) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Test App", + description="", + mode=mode, + enable_site=True, + enable_api=True, + max_active_requests=0, + ) @staticmethod - def create_end_user_mock( - user_id: str = "user-456", - session_id: str = "session-789", - ) -> MagicMock: - """Create a mock EndUser object.""" - user = MagicMock(spec=EndUser) - user.id = user_id - user.session_id = session_id - return user + def create_end_user(user_id: str = "user-456") -> EndUser: + return EndUser( + id=user_id, + tenant_id="tenant-123", + app_id="app-123", + type=EndUserType.SERVICE_API, + session_id="session-789", + ) @staticmethod - def create_conversation_mock( + def create_account(user_id: str = "account-123") -> Account: + account = Account(name="Admin", email="admin@example.com", status=AccountStatus.ACTIVE) + account.id = user_id + return account + + @staticmethod + def create_conversation( conversation_id: str = "conv-001", app_id: str = "app-123", - ) -> MagicMock: - """Create a mock Conversation object.""" - conversation = MagicMock() - conversation.id = conversation_id - conversation.app_id = app_id + *, + app_model_config_id: str | None = None, + override_model_configs: str | None = None, + ) -> Conversation: + conversation = Conversation( + id=conversation_id, + app_id=app_id, + app_model_config_id=app_model_config_id, + override_model_configs=override_model_configs, + mode=AppMode.CHAT, + name="Test conversation", + status="normal", + from_source=ConversationFromSource.API, + from_end_user_id="user-456", + ) + conversation._inputs = {} return conversation @staticmethod - def create_message_mock( + def create_message( message_id: str = "msg-001", conversation_id: str = "conv-001", - query: str = "What is AI?", - answer: str = "AI stands for Artificial Intelligence.", + app_id: str = "app-123", + *, created_at: datetime | None = None, - ) -> MagicMock: - """Create a mock Message object.""" - message = MagicMock(spec=Message) - message.id = message_id - message.conversation_id = conversation_id - message.query = query - message.answer = answer - message.created_at = created_at or datetime.now() - message.user_feedback_with_session.return_value = None - message.admin_feedback_with_session.return_value = None + from_source: ConversationFromSource = ConversationFromSource.API, + from_end_user_id: str | None = "user-456", + from_account_id: str | None = None, + ) -> Message: + message = Message( + id=message_id, + app_id=app_id, + conversation_id=conversation_id, + query="What is AI?", + message={"role": "user", "content": "What is AI?"}, + answer="AI stands for Artificial Intelligence.", + message_unit_price=Decimal("0.0001"), + answer_unit_price=Decimal("0.0002"), + currency="USD", + from_source=from_source, + from_end_user_id=from_end_user_id, + from_account_id=from_account_id, + ) + message._inputs = {} + timestamp = created_at or datetime.now() + message.created_at = timestamp + message.updated_at = timestamp return message + @staticmethod + def create_feedback( + feedback_id: str, + message: Message, + *, + source: FeedbackFromSource, + rating: FeedbackRating = FeedbackRating.LIKE, + ) -> MessageFeedback: + feedback = MessageFeedback( + app_id=message.app_id, + conversation_id=message.conversation_id, + message_id=message.id, + rating=rating, + from_source=source, + from_end_user_id="user-456" if source == FeedbackFromSource.USER else None, + from_account_id="account-123" if source == FeedbackFromSource.ADMIN else None, + ) + feedback.id = feedback_id + return feedback + + +@pytest.fixture +def factory() -> MessageServiceTestDataFactory: + return MessageServiceTestDataFactory() + + +@pytest.fixture(autouse=True) +def database_boundaries( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, +) -> Iterator[None]: + """Bind global model properties and service-owned factories to the shared SQLite session.""" + sessions = scoped_session(lambda: sqlite_session) + database = _DatabaseBinding(engine=sqlite_engine, session=sessions) + monkeypatch.setattr(service_module, "db", database) + monkeypatch.setattr(model_module, "db", database) + try: + yield + finally: + sessions.remove() + + +@pytest.fixture +def empty_extra_content_repository(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + repository = MagicMock() + repository.get_by_message_ids.side_effect = lambda message_ids: [[] for _ in message_ids] + monkeypatch.setattr(service_module, "_create_execution_extra_content_repository", lambda: repository) + return repository + + +def _persist(session: Session, *records: object) -> None: + session.add_all(records) + session.commit() + + +def _patch_conversation(monkeypatch: pytest.MonkeyPatch, conversation: Conversation) -> MagicMock: + get_conversation = MagicMock(return_value=conversation) + monkeypatch.setattr(service_module.ConversationService, "get_conversation", get_conversation) + return get_conversation + class TestMessageServicePaginationByFirstId: - """ - Unit tests for MessageService.pagination_by_first_id method. + """Verify cursor pagination using persisted message timestamps and IDs.""" - This test suite covers: - - Basic pagination with and without first_id - - Order handling (asc/desc) - - Edge cases (no user, no conversation, invalid first_id) - - Has_more flag logic - """ - - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 01: No user provided - def test_pagination_by_first_id_no_user(self, factory: TestMessageServiceFactory): - """Test pagination returns empty result when no user is provided.""" - # Arrange - app = factory.create_app_mock() - - # Act + @pytest.mark.parametrize(("user", "conversation_id"), [(None, "conv-001"), ("end_user", "")]) + def test_early_return( + self, + user: str | None, + conversation_id: str, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: result = MessageService.pagination_by_first_id( - app_model=app, - user=None, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user() if user else None, + conversation_id=conversation_id, first_id=None, limit=10, - session=MagicMock(), + session=sqlite_session, ) - # Assert - assert isinstance(result, InfiniteScrollPagination) assert result.data == [] assert result.limit == 10 assert result.has_more is False - # Test 02: No conversation_id provided - def test_pagination_by_first_id_no_conversation(self, factory: TestMessageServiceFactory): - """Test pagination returns empty result when no conversation_id is provided.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - # Act - result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="", - first_id=None, - limit=10, - session=MagicMock(), - ) - - # Assert - assert isinstance(result, InfiniteScrollPagination) - assert result.data == [] - assert result.limit == 10 - assert result.has_more is False - - # Test 03: Basic pagination without first_id (desc order) - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_without_first_id_desc( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test basic pagination without first_id in descending order.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - # Create 5 messages + @pytest.mark.parametrize( + ("order", "expected_ids"), + [ + ("desc", ["msg-004", "msg-003", "msg-002", "msg-001", "msg-000"]), + ("asc", ["msg-000", "msg-001", "msg-002", "msg-003", "msg-004"]), + ], + ) + def test_orders_persisted_messages( + self, + order: str, + expected_ids: list[str], + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(5) ] + _persist(sqlite_session, conversation, *messages) + _patch_conversation(monkeypatch, conversation) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id=None, limit=10, - order="desc", - session=mock_db.session, + order=order, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 + assert [message.id for message in result.data] == expected_ids assert result.has_more is False - assert result.limit == 10 - # Messages should remain in desc order (not reversed) - assert result.data[0].id == "msg-000" - # Test 04: Basic pagination without first_id (asc order) - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_without_first_id_asc( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test basic pagination without first_id in ascending order.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - # Create 5 messages (returned in desc order from DB) + def test_first_id_excludes_cursor_and_newer_messages( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, 4 - i), # Descending timestamps - ) - for i in range(5) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(7) ] + _persist(sqlite_session, conversation, *messages) + _patch_conversation(monkeypatch, conversation) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", - first_id=None, - limit=10, - order="asc", - session=mock_db.session, - ) - - # Assert - assert len(result.data) == 5 - assert result.has_more is False - # Messages should be reversed to asc order - assert result.data[0].id == "msg-004" - assert result.data[4].id == "msg-000" - - # Test 05: Pagination with first_id - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_with_first_id( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test pagination with first_id to get messages before a specific message.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - first_message = factory.create_message_mock( - message_id="msg-005", - created_at=datetime(2024, 1, 1, 12, 5), - ) - - # Messages before first_message - history_messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) - ] - - mock_db.session.scalar.return_value = first_message - mock_db.session.scalars.return_value.all.return_value = history_messages - - # Act - result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id="msg-005", limit=10, order="desc", - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 - assert result.has_more is False + assert [message.id for message in result.data] == [f"msg-{index:03d}" for index in range(4, -1, -1)] - # Test 06: First message not found - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_first_message_not_exists( - self, mock_conversation_service, mock_db, factory: TestMessageServiceFactory - ): - """Test error handling when first_id doesn't exist.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() + def test_missing_first_id_raises( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + _persist(sqlite_session, conversation) + _patch_conversation(monkeypatch, conversation) - mock_conversation_service.get_conversation.return_value = conversation - - mock_db.session.scalar.return_value = None # Message not found - - # Act & Assert with pytest.raises(FirstMessageNotExistsError): MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", - first_id="nonexistent-msg", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, + first_id="missing", limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Test 07: Has_more flag when results exceed limit - @patch("services.message_service._create_execution_extra_content_repository") - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_has_more_true( - self, mock_conversation_service, mock_db, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test has_more flag is True when results exceed limit.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() - - mock_conversation_service.get_conversation.return_value = conversation - - # Create limit+1 messages (11 messages for limit=10) + def test_has_more_trims_oldest_extra_row( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(11) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) + for index in range(11) ] + _persist(sqlite_session, conversation, *messages) + _patch_conversation(monkeypatch, conversation) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id=None, limit=10, - session=mock_db.session, + order="desc", + session=sqlite_session, ) - # Assert - assert len(result.data) == 10 # Last message trimmed + assert len(result.data) == 10 assert result.has_more is True - assert result.limit == 10 + assert result.data[-1].id == "msg-001" - # Test 08: Empty conversation - @patch("services.message_service.db") - @patch("services.message_service.ConversationService") - def test_pagination_by_first_id_empty_conversation( - self, mock_conversation_service, mock_db, factory: TestMessageServiceFactory - ): - """Test pagination with conversation that has no messages.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock() + def test_empty_conversation( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + empty_extra_content_repository: MagicMock, + ) -> None: + conversation = factory.create_conversation() + _persist(sqlite_session, conversation) + _patch_conversation(monkeypatch, conversation) - mock_conversation_service.get_conversation.return_value = conversation - - mock_db.session.scalars.return_value.all.return_value = [] - - # Act result = MessageService.pagination_by_first_id( - app_model=app, - user=user, - conversation_id="conv-001", + app_model=factory.create_app(), + user=factory.create_end_user(), + conversation_id=conversation.id, first_id=None, limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 0 + assert result.data == [] assert result.has_more is False - assert result.limit == 10 class TestMessageServicePaginationByLastId: - """ - Unit tests for MessageService.pagination_by_last_id method. + """Verify reverse cursor, conversation, and include-ID filtering.""" - This test suite covers: - - Basic pagination with and without last_id - - Conversation filtering - - Include_ids filtering - - Edge cases (no user, invalid last_id) - """ - - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 09: No user provided - def test_pagination_by_last_id_no_user(self, factory: TestMessageServiceFactory): - """Test pagination returns empty result when no user is provided.""" - # Arrange - app = factory.create_app_mock() - - # Act + def test_no_user(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: result = MessageService.pagination_by_last_id( - app_model=app, - user=None, - last_id=None, - limit=10, - session=MagicMock(), + app_model=factory.create_app(), user=None, last_id=None, limit=10, session=sqlite_session ) - - # Assert - assert isinstance(result, InfiniteScrollPagination) assert result.data == [] assert result.limit == 10 assert result.has_more is False - # Test 10: Basic pagination without last_id - @patch("services.message_service.db") - def test_pagination_by_last_id_without_last_id(self, mock_db, factory: TestMessageServiceFactory): - """Test basic pagination without last_id.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - + def test_without_last_id(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(5) ] + _persist(sqlite_session, *messages) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 + assert [message.id for message in result.data] == [f"msg-{index:03d}" for index in range(4, -1, -1)] assert result.has_more is False - assert result.limit == 10 - # Test 11: Pagination with last_id - @patch("services.message_service.db") - def test_pagination_by_last_id_with_last_id(self, mock_db, factory: TestMessageServiceFactory): - """Test pagination with last_id to get messages after a specific message.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - last_message = factory.create_message_mock( - message_id="msg-005", - created_at=datetime(2024, 1, 1, 12, 5), - ) - - # Messages after last_message - new_messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(6, 10) + def test_last_id_returns_older_rows(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: + messages = [ + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(7) ] + _persist(sqlite_session, *messages) - mock_db.session.scalar.return_value = last_message - mock_db.session.scalars.return_value.all.return_value = new_messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id="msg-005", limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 4 - assert result.has_more is False + assert [message.id for message in result.data] == [f"msg-{index:03d}" for index in range(4, -1, -1)] - # Test 12: Last message not found - @patch("services.message_service.db") - def test_pagination_by_last_id_last_message_not_exists(self, mock_db, factory: TestMessageServiceFactory): - """Test error handling when last_id doesn't exist.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - mock_db.session.scalar.return_value = None # Message not found - - # Act & Assert + def test_missing_last_id_raises(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: with pytest.raises(LastMessageNotExistsError): MessageService.pagination_by_last_id( - app_model=app, - user=user, - last_id="nonexistent-msg", + app_model=factory.create_app(), + user=factory.create_end_user(), + last_id="missing", limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Test 13: Pagination with conversation_id filter - @patch("services.message_service.ConversationService") - @patch("services.message_service.db") - def test_pagination_by_last_id_with_conversation_filter( - self, mock_db, mock_conversation_service, factory: TestMessageServiceFactory - ): - """Test pagination filtered by conversation_id.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - conversation = factory.create_conversation_mock(conversation_id="conv-001") + def test_conversation_filter( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + other_conversation = factory.create_conversation("conv-002") + matching = factory.create_message("matching", conversation_id=conversation.id) + excluded = factory.create_message("excluded", conversation_id=other_conversation.id) + _persist(sqlite_session, conversation, other_conversation, matching, excluded) + get_conversation = _patch_conversation(monkeypatch, conversation) - mock_conversation_service.get_conversation.return_value = conversation - - messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - conversation_id="conv-001", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(5) - ] - - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, - conversation_id="conv-001", - session=mock_db.session, + conversation_id=conversation.id, + session=sqlite_session, ) - # Assert - assert len(result.data) == 5 - assert result.has_more is False - mock_conversation_service.get_conversation.assert_called_once() + assert [message.id for message in result.data] == [matching.id] + get_conversation.assert_called_once() - # Test 14: Pagination with include_ids filter - @patch("services.message_service.db") - def test_pagination_by_last_id_with_include_ids(self, mock_db, factory: TestMessageServiceFactory): - """Test pagination filtered by include_ids.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - # Only messages with IDs in include_ids should be returned + def test_include_ids_filter(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: messages = [ - factory.create_message_mock(message_id="msg-001"), - factory.create_message_mock(message_id="msg-003"), + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) for index in range(4) ] + _persist(sqlite_session, *messages) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, include_ids=["msg-001", "msg-003"], - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 2 - assert result.data[0].id == "msg-001" - assert result.data[1].id == "msg-003" + assert [message.id for message in result.data] == ["msg-003", "msg-001"] - # Test 15: Has_more flag when results exceed limit - @patch("services.message_service.db") - def test_pagination_by_last_id_has_more_true(self, mock_db, factory: TestMessageServiceFactory): - """Test has_more flag is True when results exceed limit.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - # Create limit+1 messages (11 messages for limit=10) + def test_has_more(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: messages = [ - factory.create_message_mock( - message_id=f"msg-{i:03d}", - created_at=datetime(2024, 1, 1, 12, i), - ) - for i in range(11) + factory.create_message(f"msg-{index:03d}", created_at=datetime(2024, 1, 1, 12, index)) + for index in range(11) ] + _persist(sqlite_session, *messages) - mock_db.session.scalars.return_value.all.return_value = messages - - # Act result = MessageService.pagination_by_last_id( - app_model=app, - user=user, + app_model=factory.create_app(), + user=factory.create_end_user(), last_id=None, limit=10, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert len(result.data) == 10 # Last message trimmed + assert len(result.data) == 10 assert result.has_more is True - assert result.limit == 10 class TestMessageServiceUtilities: - """Unit tests for MessageService module-level utility functions.""" - - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 16: attach_message_extra_contents with empty list - def test_attach_message_extra_contents_empty(self): - """Test attach_message_extra_contents with empty list does nothing.""" - # Act & Assert (should not raise error) + def test_attach_message_extra_contents_empty(self) -> None: attach_message_extra_contents([]) - # Test 17: attach_message_extra_contents with messages - @patch("services.message_service._create_execution_extra_content_repository") - def test_attach_message_extra_contents_with_messages(self, mock_create_repo, factory: TestMessageServiceFactory): - """Test attach_message_extra_contents correctly attaches content.""" - # Arrange - messages = [factory.create_message_mock(message_id="msg-1"), factory.create_message_mock(message_id="msg-2")] + def test_attach_message_extra_contents( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + ) -> None: + messages = [factory.create_message("msg-1"), factory.create_message("msg-2")] + content_one = MagicMock() + content_one.model_dump.return_value = {"key": "value1"} + content_two = MagicMock() + content_two.model_dump.return_value = {"key": "value2"} + repository = MagicMock() + repository.get_by_message_ids.return_value = [[content_one], [content_two]] + monkeypatch.setattr(service_module, "_create_execution_extra_content_repository", lambda: repository) - mock_repo = MagicMock() - mock_create_repo.return_value = mock_repo - - # Mock extra content models - mock_content1 = MagicMock() - mock_content1.model_dump.return_value = {"key": "value1"} - mock_content2 = MagicMock() - mock_content2.model_dump.return_value = {"key": "value2"} - - mock_repo.get_by_message_ids.return_value = [[mock_content1], [mock_content2]] - - # Act attach_message_extra_contents(messages) - # Assert - mock_repo.get_by_message_ids.assert_called_once_with(["msg-1", "msg-2"]) - messages[0].set_extra_contents.assert_called_once_with([{"key": "value1"}]) - messages[1].set_extra_contents.assert_called_once_with([{"key": "value2"}]) + assert messages[0].extra_contents == [{"key": "value1"}] + assert messages[1].extra_contents == [{"key": "value2"}] - # Test 18: attach_message_extra_contents with index out of bounds - @patch("services.message_service._create_execution_extra_content_repository") - def test_attach_message_extra_contents_index_out_of_bounds( - self, mock_create_repo, factory: TestMessageServiceFactory - ): - """Test attach_message_extra_contents handles missing content lists.""" - # Arrange - messages = [factory.create_message_mock(message_id="msg-1")] + def test_attach_message_extra_contents_missing_list( + self, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + ) -> None: + message = factory.create_message("msg-1") + repository = MagicMock() + repository.get_by_message_ids.return_value = [] + monkeypatch.setattr(service_module, "_create_execution_extra_content_repository", lambda: repository) - mock_repo = MagicMock() - mock_create_repo.return_value = mock_repo - mock_repo.get_by_message_ids.return_value = [] # Empty returned list + attach_message_extra_contents([message]) - # Act - attach_message_extra_contents(messages) + assert message.extra_contents == [] - # Assert - messages[0].set_extra_contents.assert_called_once_with([]) + def test_create_execution_extra_content_repository_uses_sqlite_factory(self, sqlite_engine: Engine) -> None: + repository = service_module._create_execution_extra_content_repository() - # Test 19: _create_execution_extra_content_repository - @patch("services.message_service.db") - @patch("services.message_service.sessionmaker") - @patch("services.message_service.SQLAlchemyExecutionExtraContentRepository") - def test_create_execution_extra_content_repository(self, mock_repo_class, mock_sessionmaker, mock_db): - """Test _create_execution_extra_content_repository creates expected repository.""" - from services.message_service import _create_execution_extra_content_repository - - # Act - _create_execution_extra_content_repository() - - # Assert - mock_sessionmaker.assert_called_once() - mock_repo_class.assert_called_once() + assert isinstance(repository, SQLAlchemyExecutionExtraContentRepository) + assert repository._session_maker.kw["bind"] is sqlite_engine + with repository._session_maker() as session: + assert isinstance(session, Session) class TestMessageServiceGetMessage: - """Unit tests for MessageService.get_message method.""" + @pytest.mark.parametrize("actor", ["end_user", "account"]) + def test_identity_scoped_success( + self, + actor: str, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + if actor == "end_user": + user: Account | EndUser = factory.create_end_user("end-user-123") + message = factory.create_message( + "msg-123", from_end_user_id=user.id, from_account_id=None, from_source=ConversationFromSource.API + ) + else: + user = factory.create_account("account-123") + message = factory.create_message( + "msg-123", + from_end_user_id=None, + from_account_id=user.id, + from_source=ConversationFromSource.CONSOLE, + ) + distractor = factory.create_message("wrong-app", app_id="app-456") + _persist(sqlite_session, message, distractor) - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() + result = MessageService.get_message( + app_model=factory.create_app(), user=user, message_id=message.id, session=sqlite_session + ) - # Test 20: get_message success for EndUser - @patch("services.message_service.db") - def test_get_message_end_user_success(self, mock_db, factory: TestMessageServiceFactory): - """Test get_message returns message for EndUser.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock(user_id="end-user-123") - message = factory.create_message_mock() + assert result.id == message.id - mock_db.session.scalar.return_value = message - - # Act, - result = MessageService.get_message(app_model=app, user=user, message_id="msg-123", session=mock_db.session) - - # Assert - assert result == message - - # Test 21: get_message success for Account (Admin) - @patch("services.message_service.db") - def test_get_message_account_success(self, mock_db, factory: TestMessageServiceFactory): - """Test get_message returns message for Account.""" - # Arrange - from models import Account - - app = factory.create_app_mock() - user = MagicMock(spec=Account) - user.id = "account-123" - message = factory.create_message_mock() - - mock_db.session.scalar.return_value = message - - # Act, - result = MessageService.get_message(app_model=app, user=user, message_id="msg-123", session=mock_db.session) - - # Assert - assert result == message - - # Test 22: get_message not found - @patch("services.message_service.db") - def test_get_message_not_found(self, mock_db, factory: TestMessageServiceFactory): - """Test get_message raises MessageNotExistsError when not found.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - - mock_db.session.scalar.return_value = None - - # Act & Assert + def test_not_found(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: with pytest.raises(MessageNotExistsError): - MessageService.get_message(app_model=app, user=user, message_id="msg-123", session=mock_db.session) + MessageService.get_message( + app_model=factory.create_app(), + user=factory.create_end_user(), + message_id="missing", + session=sqlite_session, + ) class TestMessageServiceFeedback: - """Unit tests for MessageService feedback-related methods.""" + def test_create_new_end_user_feedback( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + sqlite_engine: Engine, + ) -> None: + user = factory.create_end_user() + message = factory.create_message("msg-123") + _persist(sqlite_session, message) - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 23: create_feedback - new feedback for EndUser - @patch("services.message_service.db") - @patch.object(MessageService, "get_message") - def test_create_feedback_new_end_user(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): - """Test creating new feedback for an end user.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - message = factory.create_message_mock() - message.user_feedback = None - message.user_feedback_with_session.return_value = None - mock_get_message.return_value = message - - # Act - result = MessageService.create_feedback( - app_model=app, - message_id="msg-123", + feedback = MessageService.create_feedback( + app_model=factory.create_app(), + message_id=message.id, user=user, rating=FeedbackRating.LIKE, content="Good answer", - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert result.rating == FeedbackRating.LIKE - assert result.content == "Good answer" - assert result.from_source == FeedbackFromSource.USER - mock_db.session.add.assert_called_once() - mock_db.session.commit.assert_called_once() + with Session(sqlite_engine) as verification_session: + persisted = verification_session.get(MessageFeedback, feedback.id) + assert persisted is not None + assert persisted.rating == FeedbackRating.LIKE + assert persisted.content == "Good answer" + assert persisted.from_source == FeedbackFromSource.USER - # Test 24: create_feedback - update feedback for Account - @patch("services.message_service.db") - @patch.object(MessageService, "get_message") - def test_create_feedback_update_account(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): - """Test updating existing feedback for an account.""" - # Arrange - from models import Account, MessageFeedback + def test_update_account_feedback( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + sqlite_engine: Engine, + ) -> None: + user = factory.create_account() + message = factory.create_message( + "msg-123", + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id=user.id, + ) + feedback = factory.create_feedback("feedback-1", message, source=FeedbackFromSource.ADMIN) + _persist(sqlite_session, message, feedback) - app = factory.create_app_mock() - user = MagicMock(spec=Account) - user.id = "account-123" - message = factory.create_message_mock() - feedback = MagicMock(spec=MessageFeedback) - message.admin_feedback = feedback - message.admin_feedback_with_session.return_value = feedback - mock_get_message.return_value = message - - # Act result = MessageService.create_feedback( - app_model=app, - message_id="msg-123", + app_model=factory.create_app(), + message_id=message.id, user=user, rating=FeedbackRating.DISLIKE, content="Bad answer", - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert result == feedback - assert feedback.rating == FeedbackRating.DISLIKE - assert feedback.content == "Bad answer" - mock_db.session.commit.assert_called_once() + assert result.id == feedback.id + with Session(sqlite_engine) as verification_session: + persisted = verification_session.get(MessageFeedback, feedback.id) + assert persisted is not None + assert persisted.rating == FeedbackRating.DISLIKE + assert persisted.content == "Bad answer" - # Test 25: create_feedback - delete feedback (rating is None) - @patch("services.message_service.db") - @patch.object(MessageService, "get_message") - def test_create_feedback_delete(self, mock_get_message, mock_db, factory: TestMessageServiceFactory): - """Test deleting feedback by passing rating=None.""" - # Arrange - app = factory.create_app_mock() - user = factory.create_end_user_mock() - message = factory.create_message_mock() - feedback = MagicMock() - message.user_feedback = feedback - message.user_feedback_with_session.return_value = feedback - mock_get_message.return_value = message + def test_delete_feedback( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + sqlite_engine: Engine, + ) -> None: + user = factory.create_end_user() + message = factory.create_message("msg-123") + feedback = factory.create_feedback("feedback-1", message, source=FeedbackFromSource.USER) + _persist(sqlite_session, message, feedback) - # Act - result = MessageService.create_feedback( - app_model=app, - message_id="msg-123", + MessageService.create_feedback( + app_model=factory.create_app(), + message_id=message.id, user=user, rating=None, content=None, - session=mock_db.session, + session=sqlite_session, ) - # Assert - assert result == feedback - mock_db.session.delete.assert_called_once_with(feedback) - mock_db.session.commit.assert_called_once() + with Session(sqlite_engine) as verification_session: + assert verification_session.get(MessageFeedback, feedback.id) is None - # Test 26: get_all_messages_feedbacks - @patch("services.message_service.db") - def test_get_all_messages_feedbacks(self, mock_db, factory: TestMessageServiceFactory): - """Test get_all_messages_feedbacks returns list of dicts.""" - # Arrange - app = factory.create_app_mock() - feedback = MagicMock() - feedback.to_dict.return_value = {"id": "fb-1"} + def test_get_all_feedbacks_is_app_scoped_and_paginated( + self, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + message = factory.create_message("msg-123") + newest = factory.create_feedback("feedback-new", message, source=FeedbackFromSource.USER) + oldest = factory.create_feedback("feedback-old", message, source=FeedbackFromSource.USER) + other_message = factory.create_message("other-msg", app_id="app-456") + other_app = factory.create_feedback("feedback-other", other_message, source=FeedbackFromSource.USER) + newest.created_at = datetime(2024, 1, 2) + oldest.created_at = datetime(2024, 1, 1) + other_app.created_at = datetime(2024, 1, 3) + _persist(sqlite_session, newest, oldest, other_app) - mock_db.session.scalars.return_value.all.return_value = [feedback] + result = MessageService.get_all_messages_feedbacks( + app_model=factory.create_app(), page=1, limit=1, session=sqlite_session + ) - # Act, - result = MessageService.get_all_messages_feedbacks(app_model=app, page=1, limit=10, session=mock_db.session) - - # Assert - assert result == [{"id": "fb-1"}] + assert [record["id"] for record in result] == [newest.id] class TestMessageServiceSuggestedQuestions: - """Unit tests for MessageService.get_suggested_questions_after_answer method.""" + @staticmethod + def _chat_boundaries( + monkeypatch: pytest.MonkeyPatch, + conversation: Conversation, + ) -> tuple[MagicMock, MagicMock, MagicMock]: + message = MagicMock() + message.conversation_id = conversation.id + monkeypatch.setattr(service_module.MessageService, "get_message", MagicMock(return_value=message)) + monkeypatch.setattr( + service_module.ConversationService, "get_conversation", MagicMock(return_value=conversation) + ) + model_manager = MagicMock() + monkeypatch.setattr(service_module.ModelManager, "for_tenant", MagicMock(return_value=model_manager)) + memory = MagicMock() + memory.return_value.get_history_prompt_text.return_value = "histories" + monkeypatch.setattr(service_module, "TokenBufferMemory", memory) + llm_generator = MagicMock() + llm_generator.generate_suggested_questions_after_answer.return_value = ["Q1?"] + monkeypatch.setattr(service_module, "LLMGenerator", llm_generator) + monkeypatch.setattr(service_module, "TraceQueueManager", MagicMock()) + return model_manager, memory, llm_generator - @pytest.fixture - def factory(self): - """Provide test data factory.""" - return TestMessageServiceFactory() - - # Test 27: get_suggested_questions_after_answer - user is None - def test_get_suggested_questions_user_none(self, factory: TestMessageServiceFactory): - app = factory.create_app_mock() + def test_user_none(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None: with pytest.raises(ValueError, match="user cannot be None"): MessageService.get_suggested_questions_after_answer( - app_model=app, + app_model=factory.create_app(), user=None, message_id="msg-123", - invoke_from=MagicMock(), - session=MagicMock(), + invoke_from=InvokeFrom.WEB_APP, + session=sqlite_session, ) - # Test 28: get_suggested_questions_after_answer - Advanced Chat success - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.WorkflowService") - @patch("services.message_service.AdvancedChatAppConfigManager") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_advanced_chat_success( + def test_advanced_chat_success( self, - mock_conversation_service, - mock_get_message, - mock_trace_manager, - mock_llm_gen, - mock_memory, - mock_config_manager, - mock_workflow_service, - mock_model_manager, - factory: TestMessageServiceFactory, - ): - """Test successful suggested questions generation in Advanced Chat mode.""" - from core.app.entities.app_invoke_entities import InvokeFrom - - # Arrange - app = factory.create_app_mock(mode=AppMode.ADVANCED_CHAT.value) - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation) workflow = MagicMock() - mock_workflow_service.return_value.get_published_workflow.return_value = workflow + workflow.features_dict = {"suggested_questions_after_answer": {"enabled": True}} + workflow_service = MagicMock() + workflow_service.return_value.get_published_workflow.return_value = workflow + monkeypatch.setattr(service_module, "WorkflowService", workflow_service) + app_config_manager = MagicMock() + app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = True + monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager) - app_config = MagicMock() - app_config.additional_features.suggested_questions_after_answer = True - mock_config_manager.get_app_config.return_value = app_config - - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] - - # Act result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT), + user=factory.create_end_user(), message_id="msg-123", invoke_from=InvokeFrom.WEB_APP, - session=MagicMock(), + session=sqlite_session, ) - # Assert assert result == ["Q1?"] - mock_workflow_service.return_value.get_published_workflow.assert_called_once() - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once() + llm_generator.generate_suggested_questions_after_answer.assert_called_once() - # Test 29: get_suggested_questions_after_answer - Chat app success (no override) - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_success( + @pytest.mark.parametrize( + ("config", "expected_prompt", "expected_model"), + [ + ({"enabled": True}, None, None), + ( + { + "enabled": True, + "prompt": "custom prompt", + "model": { + "provider": "openai", + "name": "gpt-4o-mini", + "completion_params": {"max_tokens": 2048, "temperature": 0.1}, + }, + }, + "custom prompt", + { + "provider": "openai", + "name": "gpt-4o-mini", + "completion_params": {"max_tokens": 2048, "temperature": 0.1}, + }, + ), + ( + {"enabled": True, "model": {"provider": "openai", "name": "invalid-model"}}, + None, + {"provider": "openai", "name": "invalid-model"}, + ), + ], + ) + def test_chat_app_uses_persisted_model_config( self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test successful suggested questions generation in basic Chat mode.""" - # Arrange - app = factory.create_app_mock(mode=AppMode.CHAT) - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = None - mock_conversation_service.get_conversation.return_value = conversation - - app_model_config = MagicMock() - app_model_config.suggested_questions_after_answer_dict = {"enabled": True} - app_model_config.model_dict = {"provider": "openai", "name": "gpt-4"} - - mock_db.session.scalar.return_value = app_model_config - - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] - - # Act - result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, - message_id="msg-123", - invoke_from=MagicMock(), - session=mock_db.session, + config: dict[str, object], + expected_prompt: str | None, + expected_model: dict[str, object] | None, + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + app_model_config = AppModelConfig( + app_id="app-123", + suggested_questions_after_answer=json.dumps(config), ) - - # Assert - assert result == ["Q1?"] - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once() - - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_uses_frontend_model_and_prompt( - self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test suggested question generation uses frontend configured model and prompt.""" - from core.app.entities.app_invoke_entities import InvokeFrom - - app = factory.create_app_mock(mode=AppMode.CHAT) - app.tenant_id = "tenant-123" - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = None - mock_conversation_service.get_conversation.return_value = conversation - - app_model_config = MagicMock() - app_model_config.suggested_questions_after_answer_dict = { - "enabled": True, - "prompt": "custom prompt", - "model": { - "provider": "openai", - "name": "gpt-4o-mini", - "completion_params": {"max_tokens": 2048, "temperature": 0.1}, - }, - } - mock_db.session.scalar.return_value = app_model_config - - mock_memory.return_value.get_history_prompt_text.return_value = "histories" - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] + app_model_config.id = "config-1" + conversation = factory.create_conversation(app_model_config_id=app_model_config.id) + _persist(sqlite_session, app_model_config) + model_manager, memory, llm_generator = self._chat_boundaries(monkeypatch, conversation) result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.CHAT), + user=factory.create_end_user(), message_id="msg-123", invoke_from=InvokeFrom.WEB_APP, - session=mock_db.session, + session=sqlite_session, ) assert result == ["Q1?"] - mock_model_manager.return_value.get_default_model_instance.assert_called_once_with( - tenant_id="tenant-123", - model_type=ModelType.LLM, + model_manager.get_default_model_instance.assert_called_once_with( + tenant_id="tenant-123", model_type=ModelType.LLM ) - mock_memory.assert_called_once_with( + memory.assert_called_once_with( conversation=conversation, - model_instance=mock_model_manager.return_value.get_default_model_instance.return_value, + model_instance=model_manager.get_default_model_instance.return_value, ) - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with( + llm_generator.generate_suggested_questions_after_answer.assert_called_once_with( tenant_id="tenant-123", histories="histories", - instruction_prompt="custom prompt", - model_config={ - "provider": "openai", - "name": "gpt-4o-mini", - "completion_params": {"max_tokens": 2048, "temperature": 0.1}, - }, + instruction_prompt=expected_prompt, + model_config=expected_model, ) - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_invalid_frontend_model_fallback_to_default( + def test_chat_app_uses_compatible_override_model_config( self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test invalid frontend configured model falls back to tenant default model.""" - app = factory.create_app_mock(mode=AppMode.CHAT) - app.tenant_id = "tenant-123" - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = None - mock_conversation_service.get_conversation.return_value = conversation - - app_model_config = MagicMock() - app_model_config.suggested_questions_after_answer_dict = { - "enabled": True, - "model": {"provider": "openai", "name": "invalid-model"}, - } - mock_db.session.scalar.return_value = app_model_config - - mock_model_manager.return_value.get_model_instance.side_effect = ValueError("invalid model") - mock_memory.return_value.get_history_prompt_text.return_value = "histories" - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] - - result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, - message_id="msg-123", - invoke_from=MagicMock(), - session=mock_db.session, - ) - - assert result == ["Q1?"] - mock_model_manager.return_value.get_default_model_instance.assert_called_once_with( - tenant_id="tenant-123", - model_type=ModelType.LLM, - ) - mock_model_manager.return_value.get_model_instance.assert_not_called() - - @patch("services.message_service.db") - @patch("services.message_service.ModelManager.for_tenant") - @patch("services.message_service.TokenBufferMemory") - @patch("services.message_service.LLMGenerator") - @patch("services.message_service.TraceQueueManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_chat_app_uses_compatible_override_model_config( - self, - mock_conversation_service: MagicMock, - mock_get_message: MagicMock, - mock_trace_manager: MagicMock, - mock_llm_gen: MagicMock, - mock_memory: MagicMock, - mock_model_manager: MagicMock, - mock_db: MagicMock, - factory: TestMessageServiceFactory, - ): - """Test legacy override configs are normalized before suggested questions reads them.""" - app = factory.create_app_mock(mode=AppMode.CHAT) - app.tenant_id = "tenant-123" - user = factory.create_end_user_mock() - message = factory.create_message_mock() - mock_get_message.return_value = message - - conversation = MagicMock() - conversation.override_model_configs = json.dumps( - { - "speech_to_text": {"enabled": False}, - "text_to_speech": {"enabled": False}, - "retriever_resource": {"enabled": False}, - "model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"}, - "user_input_form": [], - "dataset_query_variable": "", - "pre_prompt": "", - "agent_mode": { - "enabled": False, - "max_iteration": 5, - "strategy": "function_call", - "tools": [], - }, - "prompt_type": "simple", - "chat_prompt_config": {}, - "completion_prompt_config": {}, - "dataset_configs": {"retrieval_model": "single", "datasets": {"datasets": []}}, - "file_upload": { - "image": { - "detail": "high", - "enabled": False, - "number_limits": 3, - "transfer_methods": ["remote_url", "local_file"], - } - }, - "suggested_questions_after_answer": { - "enabled": True, - "prompt": "legacy prompt", - }, - } - ) - conversation.model_config = { - "opening_statement": None, - "suggested_questions": [], - "suggested_questions_after_answer": { - "enabled": True, - "prompt": "legacy prompt", - }, - "speech_to_text": {"enabled": False}, - "text_to_speech": {"enabled": False}, - "retriever_resource": {"enabled": False}, - "annotation_reply": {"enabled": False}, - "more_like_this": {"enabled": False}, - "sensitive_word_avoidance": {"enabled": False, "type": "", "config": {}}, - "external_data_tools": [], + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + override = { "model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"}, - "user_input_form": [], - "dataset_query_variable": "", - "pre_prompt": "", - "agent_mode": {"enabled": False, "strategy": "function_call", "tools": [], "prompt": None}, - "prompt_type": "simple", - "chat_prompt_config": {}, - "completion_prompt_config": {}, - "dataset_configs": {"retrieval_model": "single", "datasets": {"datasets": []}}, - "file_upload": { - "image": { - "detail": "high", - "enabled": False, - "number_limits": 3, - "transfer_methods": ["remote_url", "local_file"], - } - }, - "model_id": None, - "provider": None, + "suggested_questions_after_answer": {"enabled": True, "prompt": "legacy prompt"}, } - conversation.model_config_with_session.return_value = conversation.model_config - mock_conversation_service.get_conversation.return_value = conversation - - mock_memory.return_value.get_history_prompt_text.return_value = "histories" - mock_llm_gen.generate_suggested_questions_after_answer.return_value = ["Q1?"] + conversation = factory.create_conversation(override_model_configs=json.dumps(override)) + _, _, llm_generator = self._chat_boundaries(monkeypatch, conversation) result = MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.CHAT), + user=factory.create_end_user(), message_id="msg-123", - invoke_from=MagicMock(), - session=mock_db.session, + invoke_from=InvokeFrom.WEB_APP, + session=sqlite_session, ) assert result == ["Q1?"] - mock_db.session.scalar.assert_not_called() - mock_llm_gen.generate_suggested_questions_after_answer.assert_called_once_with( + llm_generator.generate_suggested_questions_after_answer.assert_called_once_with( tenant_id="tenant-123", histories="histories", instruction_prompt="legacy prompt", model_config=None, ) - # Test 30: get_suggested_questions_after_answer - Disabled Error - @patch("services.message_service.WorkflowService") - @patch("services.message_service.AdvancedChatAppConfigManager") - @patch.object(MessageService, "get_message") - @patch("services.message_service.ConversationService") - def test_get_suggested_questions_disabled_error( + def test_disabled_error( self, - mock_conversation_service, - mock_get_message, - mock_config_manager, - mock_workflow_service, - factory: TestMessageServiceFactory, - ): - """Test SuggestedQuestionsAfterAnswerDisabledError is raised when feature is disabled.""" - # Arrange - app = factory.create_app_mock(mode=AppMode.ADVANCED_CHAT.value) - user = factory.create_end_user_mock() - mock_get_message.return_value = factory.create_message_mock() - + monkeypatch: pytest.MonkeyPatch, + factory: MessageServiceTestDataFactory, + sqlite_session: Session, + ) -> None: + conversation = factory.create_conversation() + self._chat_boundaries(monkeypatch, conversation) workflow = MagicMock() - mock_workflow_service.return_value.get_published_workflow.return_value = workflow + workflow_service = MagicMock() + workflow_service.return_value.get_published_workflow.return_value = workflow + monkeypatch.setattr(service_module, "WorkflowService", workflow_service) + app_config_manager = MagicMock() + app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = False + monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager) - app_config = MagicMock() - app_config.additional_features.suggested_questions_after_answer = False - mock_config_manager.get_app_config.return_value = app_config - - # Act & Assert with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError): MessageService.get_suggested_questions_after_answer( - app_model=app, - user=user, + app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT), + user=factory.create_end_user(), message_id="msg-123", - invoke_from=MagicMock(), - session=MagicMock(), + invoke_from=InvokeFrom.WEB_APP, + session=sqlite_session, ) From d5c0e927c6d62c7b00929af8c0d39cc384e96135 Mon Sep 17 00:00:00 2001 From: David Park <163079241+dparkmit24@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:03:17 +0000 Subject: [PATCH 014/531] fix(web): stop chunk length/overlap inputs collapsing to an unusable width in narrow containers (#39600) Co-authored-by: Claude Opus 4.8 (1M context) --- .../general-chunking-options.spec.tsx | 26 ++++++++++++++ .../components/__tests__/inputs.spec.tsx | 35 +++++++++++++++++++ .../components/general-chunking-options.tsx | 6 ++-- .../create/step-two/components/inputs.tsx | 13 +++++-- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx index 916c848d7d8..1d5ca3de6e4 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx @@ -197,4 +197,30 @@ describe('GeneralChunkingOptions', () => { expect(onSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true }) }) }) + + // Regression: dify issue 39592 — the delimiter/max-length/overlap row must + // stack (one field per row) in a narrow card and sit three-across when wide, + // driven by a container query rather than the viewport. Fails before this + // change (row was flex-wrap; no @container ancestor). + describe('#39592 narrow-container regression', () => { + it('stacks by default and becomes a row across the 552px container query', () => { + render() + const delimiterLabel = screen.getByText(`${ns}.stepTwo.separator`) + const row = delimiterLabel.closest('.gap-3') + expect(row).not.toBeNull() + // stacked by default (below threshold) + expect(row!.className).toContain('flex-col') + // three-across at/above the container threshold + expect(row!.className).toContain('@min-[552px]/chunkfields:flex-row') + // the previous flex-wrap approach is gone + expect(row!.className).not.toContain('flex-wrap') + }) + + it('marks an ancestor as the query container', () => { + render() + const delimiterLabel = screen.getByText(`${ns}.stepTwo.separator`) + const container = delimiterLabel.closest('[class*="@container/chunkfields"]') + expect(container).not.toBeNull() + }) + }) }) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx index 0e6cc100763..c74a7244b71 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -136,3 +136,38 @@ describe('OverlapInput', () => { expect(onChange).toHaveBeenLastCalledWith(100) }) }) + +// Regression: dify issue 39592 — in a narrow card the number inputs collapsed +// to a 32px, unusable sliver. The fix reflows on the container (a +// @container/chunkfields ancestor, see general-chunking-options): below 552px +// each field stacks and is capped at max-w-[288px] so the input reads as a form +// field; at/above 552px it restores flex-1 (three across, pixel-identical to +// stock). The input keeps a min-width floor as the belt against re-collapse. +// jsdom has no layout engine, so we cannot assert pixel widths — we assert the +// structural classes. These fail before this change and pass after. +describe('#39592 narrow-container regression (structural)', () => { + it('gives the MaxLength input a min-width floor so it can never collapse to a sliver', () => { + render() + const input = screen.getByRole('textbox') + expect(input.className).toContain('min-w-[64px]') + }) + + it('gives the OverlapInput input a min-width floor too', () => { + render() + const input = screen.getByRole('textbox') + expect(input.className).toContain('min-w-[64px]') + }) + + it('caps each field width when stacked and restores flex-1 across the 552px container query', () => { + render() + const field = screen.getByRole('textbox').closest('.space-y-2') + expect(field).not.toBeNull() + // stacked (default / below threshold): constrained width, no stretch + expect(field!.className).toContain('max-w-[288px]') + // three-across (at/above threshold): restore flex-1 and drop the cap + expect(field!.className).toContain('@min-[552px]/chunkfields:flex-1') + expect(field!.className).toContain('@min-[552px]/chunkfields:max-w-none') + // the previous flex-wrap approach is gone + expect(field!.className).not.toContain('basis-[176px]') + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx index 564a7df3cfe..7240690791d 100644 --- a/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx +++ b/web/app/components/datasets/create/step-two/components/general-chunking-options.tsx @@ -127,8 +127,10 @@ export const GeneralChunkingOptions: FC = ({ } noHighlight={isInUpload && isNotUploadInEmptyDataset} > -
-
+
+ {/* Container query, not a viewport breakpoint: three across at/above a + 552px container, stacked one-per-row below (see inputs.tsx FormField). */} +
onSegmentIdentifierChange(e.target.value)} diff --git a/web/app/components/datasets/create/step-two/components/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx index afb2788a26e..df3fc8ab8c6 100644 --- a/web/app/components/datasets/create/step-two/components/inputs.tsx +++ b/web/app/components/datasets/create/step-two/components/inputs.tsx @@ -5,6 +5,7 @@ import type { } from '@langgenius/dify-ui/number-field' import type { FC, PropsWithChildren, ReactNode } from 'react' import type { InputProps } from '@/app/components/base/input' +import { cn } from '@langgenius/dify-ui/cn' import { NumberField, NumberFieldControls, @@ -30,7 +31,12 @@ const TextLabel: FC = (props) => { const FormField: FC> = (props) => { return ( -
+ // Reflow on the container (a @container/chunkfields ancestor), not the + // viewport. Below 552px the fields stack one per row, each capped at + // max-w-[288px] so the input reads as a form field, not a full-bleed bar. + // At/above 552px this restores flex-1 with no cap, so three columns resolve + // to (container - gaps)/3 — pixel-identical to the stock flex-1 layout. +
{props.label} {props.children}
@@ -142,7 +148,10 @@ function CompoundNumberInput({ {...inputProps} aria-label={label} size={size} - className={className} + // min-w-[64px] overrides the component's default min-w-0 so the input + // can never collapse to an unusable sliver, even in an unforeseen + // container; belt to the row's flex-wrap braces. + className={cn('min-w-[64px]', className)} onBlur={onBlur} /> {Boolean(unit) && {unit}} From 858b3b74dcb27d49c16fb0d75fccb962e2831428 Mon Sep 17 00:00:00 2001 From: FFXN <31929997+FFXN@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:04:28 +0800 Subject: [PATCH 015/531] feat: add CE telemetry report (#39452) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/.env.example | 1 - api/configs/feature/__init__.py | 36 ++ api/extensions/ext_celery.py | 27 ++ ...e1b_add_telemetry_fields_to_dify_setups.py | 30 ++ api/models/model.py | 3 + api/services/account_service.py | 8 +- api/services/telemetry_service.py | 165 +++++++++ api/tasks/community_telemetry_task.py | 19 + .../test_community_telemetry_celery.py | 43 +++ .../services/test_account_service.py | 40 ++- .../services/test_telemetry_service.py | 333 ++++++++++++++++++ .../tasks/test_community_telemetry_task.py | 40 +++ 12 files changed, 741 insertions(+), 4 deletions(-) create mode 100644 api/migrations/versions/2026_07_23_1200-6f5a9c2d8e1b_add_telemetry_fields_to_dify_setups.py create mode 100644 api/services/telemetry_service.py create mode 100644 api/tasks/community_telemetry_task.py create mode 100644 api/tests/unit_tests/extensions/test_community_telemetry_celery.py create mode 100644 api/tests/unit_tests/services/test_telemetry_service.py create mode 100644 api/tests/unit_tests/tasks/test_community_telemetry_task.py diff --git a/api/.env.example b/api/.env.example index 2adde29d334..3e600365806 100644 --- a/api/.env.example +++ b/api/.env.example @@ -729,7 +729,6 @@ OTEL_MAX_EXPORT_BATCH_SIZE=512 OTEL_METRIC_EXPORT_INTERVAL=60000 OTEL_BATCH_EXPORT_TIMEOUT=10000 OTEL_METRIC_EXPORT_TIMEOUT=30000 - # Prevent Clickjacking ALLOW_EMBED=false diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index c28716e3b0e..70c629b8070 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -816,6 +816,41 @@ class UpdateConfig(BaseSettings): ) +class CommunityTelemetryConfig(BaseSettings): + """ + Configuration for anonymous self-hosted community telemetry. + """ + + DISABLE_TELEMETRY: bool = Field( + description="Disable anonymous community telemetry", + default=False, + ) + DO_NOT_TRACK: bool = Field( + description="Respect the standard do-not-track opt-out signal for telemetry", + default=False, + ) + TELEMETRY_ENDPOINT: str = Field( + description="Endpoint for anonymous community telemetry events", + default="https://otel.dify.ai/v1/events", + ) + TELEMETRY_FALLBACK_ENDPOINT: str = Field( + description="Fallback endpoint for anonymous community telemetry events", + default="https://otel.dify.cn/v1/events", + ) + TELEMETRY_TIMEOUT_SECONDS: PositiveInt = Field( + description="HTTP timeout in seconds for anonymous community telemetry requests", + default=3, + ) + TELEMETRY_HEARTBEAT_INTERVAL_MINUTES: PositiveInt = Field( + description="Celery beat interval in minutes for checking whether heartbeat telemetry is due", + default=30, + ) + CI: bool = Field( + description="Whether the process is running in CI; telemetry is skipped when true", + default=False, + ) + + class WorkflowVariableTruncationConfig(BaseSettings): WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE: PositiveInt = Field( # 1000 KiB @@ -1599,6 +1634,7 @@ class FeatureConfig( TenantIsolatedTaskQueueConfig, ToolConfig, UpdateConfig, + CommunityTelemetryConfig, WorkflowConfig, WorkflowNodeExecutionConfig, WorkspaceConfig, diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 2cf3505e918..690fa64cdf8 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -5,6 +5,7 @@ from typing import Any import pytz # type: ignore[import-untyped] from celery import Celery, Task from celery.schedules import crontab +from celery.signals import beat_init from typing_extensions import TypedDict from configs import dify_config @@ -36,6 +37,19 @@ class CeleryBeatScheduleEntry(TypedDict): schedule: crontab | timedelta +def _enqueue_initial_community_telemetry_heartbeat(sender: Any, **_: Any) -> None: + task_name = "community_telemetry.send_heartbeat" + if "community_telemetry_heartbeat" not in sender.app.conf.beat_schedule: + return + + task = sender.app.tasks.get(task_name) + if task is not None: + task.apply_async() + + +beat_init.connect(_enqueue_initial_community_telemetry_heartbeat, weak=False) + + def get_celery_ssl_options() -> CelerySSLOptionsDict | None: """Get SSL configuration for Celery broker/backend connections.""" # Only apply SSL if we're using Redis as broker/backend @@ -260,6 +274,19 @@ def init_app(app: DifyApp) -> Celery: "schedule": timedelta(minutes=dify_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL), } + if ( + dify_config.EDITION == "SELF_HOSTED" + and not dify_config.ENTERPRISE_ENABLED + and not dify_config.DISABLE_TELEMETRY + and not dify_config.DO_NOT_TRACK + and not dify_config.CI + ): + imports.append("tasks.community_telemetry_task") + beat_schedule["community_telemetry_heartbeat"] = { + "task": "community_telemetry.send_heartbeat", + "schedule": timedelta(minutes=dify_config.TELEMETRY_HEARTBEAT_INTERVAL_MINUTES), + } + if dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED: imports.append("tasks.enterprise_telemetry_task") celery_app.conf.update(beat_schedule=beat_schedule, imports=imports) diff --git a/api/migrations/versions/2026_07_23_1200-6f5a9c2d8e1b_add_telemetry_fields_to_dify_setups.py b/api/migrations/versions/2026_07_23_1200-6f5a9c2d8e1b_add_telemetry_fields_to_dify_setups.py new file mode 100644 index 00000000000..ca5ad4a1608 --- /dev/null +++ b/api/migrations/versions/2026_07_23_1200-6f5a9c2d8e1b_add_telemetry_fields_to_dify_setups.py @@ -0,0 +1,30 @@ +"""add telemetry fields to dify_setups + +Revision ID: 6f5a9c2d8e1b +Revises: d2825e7b9c10 +Create Date: 2026-07-23 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "6f5a9c2d8e1b" +down_revision = "d2825e7b9c10" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("dify_setups", schema=None) as batch_op: + batch_op.add_column(sa.Column("instance_id", sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column("install_reported_at", sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("dify_setups", schema=None) as batch_op: + batch_op.drop_column("last_heartbeat_at") + batch_op.drop_column("install_reported_at") + batch_op.drop_column("instance_id") diff --git a/api/models/model.py b/api/models/model.py index c9f27a78b91..bcefb1c22fd 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -362,6 +362,9 @@ class DifySetup(TypeBase): __table_args__ = (sa.PrimaryKeyConstraint("version", name="dify_setup_pkey"),) version: Mapped[str] = mapped_column(String(255), nullable=False) + instance_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + install_reported_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None) + last_heartbeat_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None) setup_at: Mapped[datetime] = mapped_column( sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) diff --git a/api/services/account_service.py b/api/services/account_service.py index cc2d983c4ef..cd89ddba2ab 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -75,6 +75,7 @@ from services.errors.account import ( from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError from services.feature_service import FeatureService from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService +from services.telemetry_service import CommunityTelemetryService from tasks.delete_account_task import delete_account_task from tasks.mail_account_deletion_task import send_account_deletion_verification_code from tasks.mail_change_mail_task import ( @@ -1953,7 +1954,7 @@ class RegisterService: TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True, session=session) - dify_setup = DifySetup(version=dify_config.project.version) + dify_setup = DifySetup(version=dify_config.project.version, instance_id=str(uuid.uuid4())) session.add(dify_setup) session.commit() except Exception as e: @@ -1966,6 +1967,11 @@ class RegisterService: logger.exception("Setup account failed, email: %s, name: %s", email, name) raise ValueError(f"Setup failed: {e}") + try: + CommunityTelemetryService.report_install(session=session) + except Exception: + logger.debug("Failed to report install telemetry", exc_info=True) + @classmethod def register( cls, diff --git a/api/services/telemetry_service.py b/api/services/telemetry_service.py new file mode 100644 index 00000000000..e6d141fe55a --- /dev/null +++ b/api/services/telemetry_service.py @@ -0,0 +1,165 @@ +import logging +import platform +import uuid +from datetime import datetime +from typing import Literal + +import httpx +from sqlalchemy import select +from sqlalchemy.orm import Session + +from configs import dify_config +from libs.datetime_utils import naive_utc_now +from models.model import DifySetup + +logger = logging.getLogger(__name__) + +TelemetryEvent = Literal["install", "heartbeat"] + +SCHEMA_VERSION = 1 + + +class CommunityTelemetryService: + @classmethod + def report_install(cls, *, session: Session) -> bool: + setup = cls._get_setup(session) + if setup is None: + return False + + if setup.instance_id is None: + setup.instance_id = str(uuid.uuid4()) + session.add(setup) + session.commit() + + payload = cls._build_payload(setup, "install") + if not cls._send_event(payload): + return False + + setup.install_reported_at = naive_utc_now() + session.add(setup) + session.commit() + return True + + @classmethod + def report_heartbeat(cls, *, session: Session, now: datetime | None = None) -> bool: + setup = cls._get_setup(session) + if setup is None: + return False + + if setup.instance_id is None: + setup.instance_id = str(uuid.uuid4()) + session.add(setup) + session.commit() + + now = now or naive_utc_now() + if not cls._is_heartbeat_due(setup, now): + return False + + if setup.install_reported_at is None: + cls.report_install(session=session) + + payload = cls._build_payload(setup, "heartbeat") + if not cls._send_event(payload): + return False + + setup.last_heartbeat_at = now + session.add(setup) + session.commit() + return True + + @classmethod + def _get_setup(cls, session: Session) -> DifySetup | None: + return session.scalar(select(DifySetup).order_by(DifySetup.setup_at.asc()).limit(1)) + + @classmethod + def _is_enabled(cls) -> bool: + return ( + dify_config.EDITION == "SELF_HOSTED" + and not dify_config.ENTERPRISE_ENABLED + and not dify_config.DISABLE_TELEMETRY + and not dify_config.DO_NOT_TRACK + and not dify_config.CI + and bool(dify_config.TELEMETRY_ENDPOINT) + ) + + @classmethod + def _build_payload(cls, setup: DifySetup, event: TelemetryEvent) -> dict[str, str | int]: + payload: dict[str, str | int] = { + "event": event, + "instance_id": setup.instance_id or "", + "version": setup.version if event == "install" else dify_config.project.version, + "edition": dify_config.EDITION, + "deployment_type": "unknown", + "schema_version": SCHEMA_VERSION, + "os": cls._normalize_os(platform.system()), + "arch": cls._normalize_arch(platform.machine()), + "sent_at": cls._format_datetime(naive_utc_now()), + } + + if event == "install": + payload["installed_at"] = cls._format_datetime(setup.setup_at) + + return payload + + @classmethod + def _send_event(cls, payload: dict[str, str | int]) -> bool: + if not cls._is_enabled(): + return False + + endpoints = [dify_config.TELEMETRY_ENDPOINT] + if dify_config.TELEMETRY_FALLBACK_ENDPOINT not in endpoints: + endpoints.append(dify_config.TELEMETRY_FALLBACK_ENDPOINT) + + for endpoint in endpoints: + if not endpoint: + continue + + try: + response = httpx.post( + endpoint, + json=payload, + timeout=dify_config.TELEMETRY_TIMEOUT_SECONDS, + ) + response.raise_for_status() + return True + except httpx.RequestError: + logger.debug("Failed to send community telemetry event to %s", endpoint, exc_info=True) + except httpx.HTTPStatusError: + logger.debug("Community telemetry endpoint returned an error: %s", endpoint, exc_info=True) + return False + + return False + + @classmethod + def _is_heartbeat_due(cls, setup: DifySetup, now: datetime) -> bool: + if setup.instance_id is None: + return False + + if setup.last_heartbeat_at is not None and setup.last_heartbeat_at.date() >= now.date(): + return False + + return True + + @staticmethod + def _format_datetime(value: datetime) -> str: + return value.replace(microsecond=0).isoformat() + "Z" + + @staticmethod + def _normalize_os(value: str) -> str: + os_name = value.lower() + if os_name in {"linux", "darwin", "windows"}: + return os_name + return "unknown" + + @staticmethod + def _normalize_arch(value: str) -> str: + arch = value.lower() + if arch in {"x86_64", "amd64"}: + return "amd64" + if arch in {"aarch64", "arm64"}: + return "arm64" + if arch.startswith("arm"): + return "arm" + if arch in {"i386", "i686", "x86"}: + return "386" + return "unknown" diff --git a/api/tasks/community_telemetry_task.py b/api/tasks/community_telemetry_task.py new file mode 100644 index 00000000000..c0c6eb46a88 --- /dev/null +++ b/api/tasks/community_telemetry_task.py @@ -0,0 +1,19 @@ +import logging + +from celery import shared_task +from sqlalchemy.orm import sessionmaker + +from extensions.ext_database import db +from services.telemetry_service import CommunityTelemetryService + +logger = logging.getLogger(__name__) + + +@shared_task(name="community_telemetry.send_heartbeat", queue="schedule_executor") +def send_community_telemetry_heartbeat() -> None: + session_factory = sessionmaker(bind=db.engine, expire_on_commit=False) + with session_factory() as session: + try: + CommunityTelemetryService.report_heartbeat(session=session) + except Exception: + logger.debug("Failed to process community telemetry heartbeat", exc_info=True) diff --git a/api/tests/unit_tests/extensions/test_community_telemetry_celery.py b/api/tests/unit_tests/extensions/test_community_telemetry_celery.py new file mode 100644 index 00000000000..58ec7641457 --- /dev/null +++ b/api/tests/unit_tests/extensions/test_community_telemetry_celery.py @@ -0,0 +1,43 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from extensions.ext_celery import _enqueue_initial_community_telemetry_heartbeat + + +def test_beat_start_enqueues_community_telemetry_heartbeat() -> None: + task = Mock() + sender = SimpleNamespace( + app=SimpleNamespace( + conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}), + tasks={"community_telemetry.send_heartbeat": task}, + ) + ) + + _enqueue_initial_community_telemetry_heartbeat(sender) + + task.apply_async.assert_called_once_with() + + +def test_beat_start_skips_community_telemetry_when_not_scheduled() -> None: + task = Mock() + sender = SimpleNamespace( + app=SimpleNamespace( + conf=SimpleNamespace(beat_schedule={}), + tasks={"community_telemetry.send_heartbeat": task}, + ) + ) + + _enqueue_initial_community_telemetry_heartbeat(sender) + + task.apply_async.assert_not_called() + + +def test_beat_start_skips_community_telemetry_when_task_is_unavailable() -> None: + sender = SimpleNamespace( + app=SimpleNamespace( + conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}), + tasks={}, + ) + ) + + _enqueue_initial_community_telemetry_heartbeat(sender) diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index ad40dab358c..e7288909a16 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -2,6 +2,7 @@ import json from collections.abc import Iterator from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +from uuid import UUID import pytest from sqlalchemy import event, select @@ -1325,7 +1326,10 @@ class TestRegisterService: with patch("services.account_service.AccountService.create_account") as mock_create_account: mock_create_account.return_value = mock_account - with patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant: + with ( + patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant, + patch("services.account_service.CommunityTelemetryService.report_install") as mock_report_install, + ): RegisterService.setup( "admin@example.com", "Admin User", @@ -1344,7 +1348,39 @@ class TestRegisterService: session=sqlite_session, ) mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True, session=sqlite_session) - assert sqlite_session.scalar(select(DifySetup)) is not None + dify_setup = sqlite_session.scalar(select(DifySetup)) + assert dify_setup is not None + assert dify_setup.instance_id is not None + assert str(UUID(dify_setup.instance_id)) == dify_setup.instance_id + assert dify_setup.install_reported_at is None + assert dify_setup.last_heartbeat_at is None + mock_report_install.assert_called_once_with(session=sqlite_session) + + def test_setup_succeeds_when_telemetry_install_report_fails( + self, sqlite_session: Session, mock_external_service_dependencies + ): + mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True + mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False + mock_account = TestAccountAssociatedDataFactory.create_account_mock() + + with ( + patch("services.account_service.AccountService.create_account", return_value=mock_account), + patch("services.account_service.TenantService.create_owner_tenant_if_not_exist"), + patch( + "services.account_service.CommunityTelemetryService.report_install", + side_effect=RuntimeError("telemetry unavailable"), + ), + ): + RegisterService.setup( + "admin@example.com", + "Admin User", + "password123", + "192.168.1.1", + "en-US", + session=sqlite_session, + ) + + assert sqlite_session.scalar(select(DifySetup)) is not None def test_setup_failure_rollback(self, sqlite_session: Session, mock_external_service_dependencies): """Test setup failure with proper rollback.""" diff --git a/api/tests/unit_tests/services/test_telemetry_service.py b/api/tests/unit_tests/services/test_telemetry_service.py new file mode 100644 index 00000000000..bec9dae012e --- /dev/null +++ b/api/tests/unit_tests/services/test_telemetry_service.py @@ -0,0 +1,333 @@ +import uuid +from datetime import datetime +from unittest.mock import Mock + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.model import DifySetup +from services import telemetry_service +from services.telemetry_service import CommunityTelemetryService + + +@pytest.fixture +def telemetry_enabled(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", False) + monkeypatch.setattr(telemetry_service.dify_config, "DO_NOT_TRACK", False) + monkeypatch.setattr(telemetry_service.dify_config, "CI", False) + monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_ENDPOINT", "https://telemetry.example.test/v1/events") + monkeypatch.setattr( + telemetry_service.dify_config, + "TELEMETRY_FALLBACK_ENDPOINT", + "https://telemetry-cn.example.test/v1/events", + ) + monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_TIMEOUT_SECONDS", 2) + + +def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", True) + + assert CommunityTelemetryService._is_enabled() is False + + +@pytest.mark.parametrize( + ("setting", "value"), + [ + ("EDITION", "CLOUD"), + ("DISABLE_TELEMETRY", True), + ("DO_NOT_TRACK", True), + ("CI", True), + ("TELEMETRY_ENDPOINT", ""), + ], +) +def test_telemetry_is_disabled_when_a_required_condition_is_not_met( + telemetry_enabled, monkeypatch: pytest.MonkeyPatch, setting: str, value: str | bool +): + monkeypatch.setattr(telemetry_service.dify_config, setting, value) + + assert CommunityTelemetryService._is_enabled() is False + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_reporting_without_setup_is_skipped(sqlite_session: Session, telemetry_enabled): + assert CommunityTelemetryService.report_install(session=sqlite_session) is False + assert CommunityTelemetryService.report_heartbeat(session=sqlite_session) is False + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_install_marks_reported_at(sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch): + setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758") + sqlite_session.add(setup) + sqlite_session.commit() + monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version") + + sent_payloads: list[dict[str, str | int]] = [] + + def fake_post(url: str, json: dict[str, str | int], timeout: int): + sent_payloads.append(json) + return httpx.Response(204, request=httpx.Request("POST", url)) + + monkeypatch.setattr(telemetry_service.httpx, "post", fake_post) + + assert CommunityTelemetryService.report_install(session=sqlite_session) is True + + saved_setup = sqlite_session.scalar(select(DifySetup)) + assert saved_setup is not None + assert saved_setup.install_reported_at is not None + assert sent_payloads[0]["event"] == "install" + assert sent_payloads[0]["instance_id"] == setup.instance_id + assert sent_payloads[0]["version"] == "installed-version" + assert "installed_at" in sent_payloads[0] + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_install_generates_missing_instance_id( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup(version="installed-version") + sqlite_session.add(setup) + sqlite_session.commit() + monkeypatch.setattr( + telemetry_service.httpx, + "post", + lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)), + ) + + assert CommunityTelemetryService.report_install(session=sqlite_session) is True + + assert setup.instance_id is not None + assert str(uuid.UUID(setup.instance_id)) == setup.instance_id + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_heartbeat_generates_missing_instance_id( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup(version="1.0.0", install_reported_at=datetime(2026, 7, 12, 8, 0, 0)) + sqlite_session.add(setup) + sqlite_session.commit() + monkeypatch.setattr( + telemetry_service.httpx, + "post", + lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)), + ) + + assert ( + CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is True + ) + + assert setup.instance_id is not None + assert str(uuid.UUID(setup.instance_id)) == setup.instance_id + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_install_failure_keeps_install_pending( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758") + sqlite_session.add(setup) + sqlite_session.commit() + + def fake_post(url: str, json: dict[str, str | int], timeout: int): + raise httpx.ConnectError("offline", request=httpx.Request("POST", url)) + + monkeypatch.setattr(telemetry_service.httpx, "post", fake_post) + + assert CommunityTelemetryService.report_install(session=sqlite_session) is False + + saved_setup = sqlite_session.scalar(select(DifySetup)) + assert saved_setup is not None + assert saved_setup.install_reported_at is None + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_install_uses_fallback_endpoint_after_network_failure( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758") + sqlite_session.add(setup) + sqlite_session.commit() + + urls: list[str] = [] + + def fake_post(url: str, json: dict[str, str | int], timeout: int): + urls.append(url) + if url == telemetry_service.dify_config.TELEMETRY_ENDPOINT: + raise httpx.ConnectError("offline", request=httpx.Request("POST", url)) + return httpx.Response(204, request=httpx.Request("POST", url)) + + monkeypatch.setattr(telemetry_service.httpx, "post", fake_post) + + assert CommunityTelemetryService.report_install(session=sqlite_session) is True + assert urls == [ + telemetry_service.dify_config.TELEMETRY_ENDPOINT, + telemetry_service.dify_config.TELEMETRY_FALLBACK_ENDPOINT, + ] + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_install_does_not_use_fallback_endpoint_after_http_error( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758") + sqlite_session.add(setup) + sqlite_session.commit() + + post_mock = Mock( + return_value=httpx.Response( + 500, + request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT), + ) + ) + monkeypatch.setattr(telemetry_service.httpx, "post", post_mock) + + assert CommunityTelemetryService.report_install(session=sqlite_session) is False + post_mock.assert_called_once() + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_heartbeat_retries_pending_install_before_heartbeat( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758") + sqlite_session.add(setup) + sqlite_session.commit() + monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version") + + sent_payloads: list[dict[str, str | int]] = [] + + def fake_post(url: str, json: dict[str, str | int], timeout: int): + sent_payloads.append(json) + return httpx.Response(204, request=httpx.Request("POST", url)) + + monkeypatch.setattr(telemetry_service.httpx, "post", fake_post) + now = datetime(2026, 7, 13, 0, 0, 0) + assert CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=now) is True + + saved_setup = sqlite_session.scalar(select(DifySetup)) + assert saved_setup is not None + assert saved_setup.install_reported_at is not None + assert saved_setup.last_heartbeat_at == now + assert [(payload["event"], payload["version"]) for payload in sent_payloads] == [ + ("install", "installed-version"), + ("heartbeat", "running-version"), + ] + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_heartbeat_skips_when_already_sent_today( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup( + version="1.0.0", + instance_id="d246c3a1-350b-406c-92c7-6043df680758", + install_reported_at=datetime(2026, 7, 13, 8, 0, 0), + last_heartbeat_at=datetime(2026, 7, 13, 9, 0, 0), + ) + sqlite_session.add(setup) + sqlite_session.commit() + + post_mock = Mock() + monkeypatch.setattr(telemetry_service.httpx, "post", post_mock) + + assert ( + CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is False + ) + post_mock.assert_not_called() + + +@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) +def test_report_heartbeat_failure_does_not_mark_the_day_reported( + sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch +): + setup = DifySetup( + version="1.0.0", + instance_id="d246c3a1-350b-406c-92c7-6043df680758", + install_reported_at=datetime(2026, 7, 13, 8, 0, 0), + ) + sqlite_session.add(setup) + sqlite_session.commit() + + def fake_post(url: str, json: dict[str, str | int], timeout: int): + raise httpx.ConnectError("offline", request=httpx.Request("POST", url)) + + monkeypatch.setattr(telemetry_service.httpx, "post", fake_post) + + assert ( + CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is False + ) + assert setup.last_heartbeat_at is None + + +def test_send_event_skips_when_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", True) + post_mock = Mock() + monkeypatch.setattr(telemetry_service.httpx, "post", post_mock) + + assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False + post_mock.assert_not_called() + + +def test_send_event_skips_an_empty_fallback_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_FALLBACK_ENDPOINT", "") + + def fake_post(url: str, json: dict[str, str], timeout: int): + raise httpx.ConnectError("offline", request=httpx.Request("POST", url)) + + monkeypatch.setattr(telemetry_service.httpx, "post", fake_post) + + assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False + + +def test_send_event_does_not_retry_the_same_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + telemetry_service.dify_config, + "TELEMETRY_FALLBACK_ENDPOINT", + telemetry_service.dify_config.TELEMETRY_ENDPOINT, + ) + post_mock = Mock( + return_value=httpx.Response( + 204, + request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT), + ) + ) + monkeypatch.setattr(telemetry_service.httpx, "post", post_mock) + + assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is True + post_mock.assert_called_once() + + +def test_heartbeat_is_not_due_without_instance_id(): + setup = DifySetup(version="1.0.0") + + assert CommunityTelemetryService._is_heartbeat_due(setup, datetime(2026, 7, 13, 12, 0, 0)) is False + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("Linux", "linux"), + ("Plan9", "unknown"), + ], +) +def test_normalize_os(value: str, expected: str): + assert CommunityTelemetryService._normalize_os(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("x86_64", "amd64"), + ("aarch64", "arm64"), + ("armv7l", "arm"), + ("i686", "386"), + ("riscv64", "unknown"), + ], +) +def test_normalize_arch(value: str, expected: str): + assert CommunityTelemetryService._normalize_arch(value) == expected diff --git a/api/tests/unit_tests/tasks/test_community_telemetry_task.py b/api/tests/unit_tests/tasks/test_community_telemetry_task.py new file mode 100644 index 00000000000..7367b420483 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_community_telemetry_task.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock + +import pytest + +from tasks import community_telemetry_task + + +def _configure_task_session(monkeypatch: pytest.MonkeyPatch) -> Mock: + session = Mock() + session_factory = MagicMock() + session_factory.return_value.__enter__.return_value = session + monkeypatch.setattr(community_telemetry_task, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(community_telemetry_task, "sessionmaker", Mock(return_value=session_factory)) + return session + + +def test_send_community_telemetry_heartbeat_reports_with_a_database_session(monkeypatch: pytest.MonkeyPatch): + session = _configure_task_session(monkeypatch) + report_heartbeat = Mock() + monkeypatch.setattr(community_telemetry_task.CommunityTelemetryService, "report_heartbeat", report_heartbeat) + + community_telemetry_task.send_community_telemetry_heartbeat.run() + + report_heartbeat.assert_called_once_with(session=session) + + +def test_send_community_telemetry_heartbeat_swallows_report_errors(monkeypatch: pytest.MonkeyPatch): + _configure_task_session(monkeypatch) + monkeypatch.setattr( + community_telemetry_task.CommunityTelemetryService, + "report_heartbeat", + Mock(side_effect=RuntimeError("telemetry unavailable")), + ) + log_debug = Mock() + monkeypatch.setattr(community_telemetry_task.logger, "debug", log_debug) + + community_telemetry_task.send_community_telemetry_heartbeat.run() + + log_debug.assert_called_once_with("Failed to process community telemetry heartbeat", exc_info=True) From f16c249b1eace9e43479f214d14f3c706b878bec Mon Sep 17 00:00:00 2001 From: Taranum Wasu <81034301+Taranum01@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:42:57 +0530 Subject: [PATCH 016/531] fix(api): honor array-element limit and byte budget for list[File] in VariableTruncator (#39218) (#39220) Co-authored-by: Taranum Wasu Co-authored-by: Cursor --- api/services/variable_truncator.py | 18 +-- .../services/test_variable_truncator.py | 115 ++++++++++++++++++ 2 files changed, 124 insertions(+), 9 deletions(-) diff --git a/api/services/variable_truncator.py b/api/services/variable_truncator.py index 00aa31650c2..78e917b052a 100644 --- a/api/services/variable_truncator.py +++ b/api/services/variable_truncator.py @@ -278,14 +278,14 @@ class VariableTruncator(BaseTruncator): target_length = self._array_element_limit for i, item in enumerate(value): - # Dirty fix: - # The output of `Start` node may contain list of `File` elements, - # causing `AssertionError` while invoking `_truncate_json_primitives`. - # - # This check ensures that `list[File]` are handled separately - if isinstance(item, File): - truncated_value.append(item) - continue + # ``File`` is routed through ``_truncate_json_primitives`` (whose + # dedicated ``File`` branch returns the file as-is with its real + # serialized size). That preserves the count cap + # (``array_element_limit``) and the byte budget (``target_size``) + # for ``list[File]`` — the original "Dirty fix" branch above this + # loop bypassed both guarantees and reported ``used_size=2`` even + # when the returned array serialized to well over the budget. + # See https://github.com/langgenius/dify/issues/39218. if i >= target_length: return _PartResult(truncated_value, used_size, True) if i > 0: @@ -295,7 +295,7 @@ class VariableTruncator(BaseTruncator): break remaining_budget = target_size - used_size - if item is None or isinstance(item, (str, list, dict, bool, int, float, UpdatedVariable)): + if item is None or isinstance(item, (str, list, dict, bool, int, float, File, UpdatedVariable)): part_result = self._truncate_json_primitives(item, remaining_budget) else: raise UnknownTypeError(f"got unknown type {type(item)} in array truncation") diff --git a/api/tests/unit_tests/services/test_variable_truncator.py b/api/tests/unit_tests/services/test_variable_truncator.py index 931e96ef3a7..f2ba1784a28 100644 --- a/api/tests/unit_tests/services/test_variable_truncator.py +++ b/api/tests/unit_tests/services/test_variable_truncator.py @@ -673,3 +673,118 @@ def test_dummy_variable_truncator_methods(): assert isinstance(result, TruncationResult) assert result.result == segment assert result.truncated is False + + +# --------------------------------------------------------------------------- +# Regression tests for langgenius/dify#39218. +# +# Before the fix, ``_truncate_array`` had a "Dirty fix" branch that +# unconditionally appended every ``File`` element to ``truncated_value`` +# *before* the count cap and the byte-budget check, and *before* +# ``used_size`` was ever incremented. That made ``list[File]`` arrays: +# 1. uncapped by ``array_element_limit``, +# 2. uncounted against ``max_size_bytes``, and +# 3. always reported ``truncated=False``. +# The fix routes ``File`` through ``_truncate_json_primitives``'s dedicated +# ``File`` branch, which returns the file as-is with its real serialized +# size, while preserving the count cap and the byte budget. +# --------------------------------------------------------------------------- + + +class TestFileArrayTruncationRegression39218: + """``list[File]`` must respect ``array_element_limit`` and the byte budget.""" + + @pytest.fixture + def truncator(self) -> VariableTruncator: + return VariableTruncator( + array_element_limit=3, + max_size_bytes=1000, + string_length_limit=50, + ) + + @staticmethod + def _make_file(name: str = "f") -> File: + return File( + id=name, + type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.REMOTE_URL, + remote_url=f"https://example.com/{name}.txt", + filename=f"{name}.txt", + extension=".txt", + mime_type="text/plain", + size=1024, + ) + + def test_file_array_respects_element_count_cap(self, truncator: VariableTruncator) -> None: + # Use a target_size larger than ``count * file_size`` so the byte + # budget never binds — only the count cap should fire. + # Each File serializes to ~237 bytes; 3 files = ~713 bytes. + files = [self._make_file(f"f{i}") for i in range(500)] + + result = truncator._truncate_array(files, target_size=10_000_000) + + # Before the fix, all 500 File entries survived (``len(value)==500``, + # ``truncated==False``). After the fix, the array is capped at + # ``array_element_limit=3`` and ``truncated`` flips to True. + assert len(result.value) == 3 + assert result.truncated is True + + def test_file_array_reports_real_used_size(self, truncator: VariableTruncator) -> None: + # Large budget so the count cap fires before the byte budget does. + files = [self._make_file(f"f{i}") for i in range(500)] + + result = truncator._truncate_array(files, target_size=10_000_000) + + # Before the fix, ``used_size`` for a File array was the empty-array + # baseline of 2 bytes (``[]``), regardless of how many File entries + # actually returned. After the fix, ``used_size`` reflects the real + # serialized size of the returned ``File`` payload. + assert result.value_size > 100 + assert result.truncated is True + + def test_file_array_respects_byte_budget(self, truncator: VariableTruncator) -> None: + # Use a small ``target_size`` so the byte budget is the binding + # constraint. Each File serializes to ~237 bytes, so even one File + # blows the 200-byte budget. + files = [self._make_file(f"f{i}") for i in range(50)] + + result = truncator._truncate_array(files, target_size=200) + + # Before the fix, all 50 File entries survived and ``used_size`` + # reported ``2`` (the empty-array baseline). After the fix, the + # loop sees the File payload: ``value_size`` reflects the real + # serialized size, and the loop stops after the first File because + # adding the next one would exceed ``target_size``. + assert len(result.value) == 1 + assert result.value_size > 100 # the File's real serialized size + assert result.value_size <= 250 # in the ballpark of the budget + + def test_mixed_array_counts_files_toward_cap(self, truncator: VariableTruncator) -> None: + mixed: list[object] = [ + self._make_file("f0"), + "a", + self._make_file("f1"), + "b", + self._make_file("f2"), + "c", + self._make_file("f3"), + "d", + ] + + result = truncator._truncate_array(mixed, target_size=10_000_000) + + # 8 items, cap of 3 → exactly 3 items. Files and primitives are + # counted together toward the cap. + assert len(result.value) == 3 + assert result.truncated is True + + def test_single_file_in_array_is_preserved(self, truncator: VariableTruncator) -> None: + result = truncator._truncate_array([self._make_file("only")], target_size=10_000_000) + + # The File itself is not truncated — the dedicated ``File`` branch + # in ``_truncate_json_primitives`` returns the file untouched. Only + # the array-shape accounting changes. + assert len(result.value) == 1 + assert isinstance(result.value[0], File) + assert result.value[0].id == "only" + assert result.truncated is False From 441f9f9ec0ee834cf2d3ea45e676a127a0a68d71 Mon Sep 17 00:00:00 2001 From: FFXN <31929997+FFXN@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:43:32 +0800 Subject: [PATCH 017/531] fix: The expiration time for web app login to JWT is incorrectly set. (#39537) --- api/services/webapp_auth_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index 33267c53d5c..3373a5ddf66 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -116,7 +116,7 @@ class WebAppAuthService: @classmethod def _get_account_jwt_token(cls, account: Account) -> str: - exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 24) + exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES) exp = int(exp_dt.timestamp()) payload = { From aeda37db6871b05c9f567e38b9f7e61c642d35fe Mon Sep 17 00:00:00 2001 From: Madan kumar Date: Mon, 27 Jul 2026 08:23:33 +0530 Subject: [PATCH 018/531] =?UTF-8?q?fix(rag):=20stop=20the=20document=20cle?= =?UTF-8?q?aner=20from=20stripping=20valid=20characters=20=C3=AF,=20=C2=BF?= =?UTF-8?q?,=20=C2=BE=20(#39215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/core/indexing_runner.py | 2 +- api/core/rag/cleaner/clean_processor.py | 2 +- .../core/rag/cleaner/test_clean_processor.py | 17 +++++++++++++++++ .../core/rag/indexing/test_indexing_runner.py | 13 +++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index 92246b6614c..63e00f48324 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -519,7 +519,7 @@ class IndexingRunner: def filter_string(text): text = re.sub(r"<\|", "<", text) text = re.sub(r"\|>", ">", text) - text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\xEF\xBF\xBE]", "", text) + text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text) # Unicode U+FFFE text = re.sub("\ufffe", "", text) return text diff --git a/api/core/rag/cleaner/clean_processor.py b/api/core/rag/cleaner/clean_processor.py index 790253053de..452251584e6 100644 --- a/api/core/rag/cleaner/clean_processor.py +++ b/api/core/rag/cleaner/clean_processor.py @@ -9,7 +9,7 @@ class CleanProcessor: # remove invalid symbol text = re.sub(r"<\|", "<", text) text = re.sub(r"\|>", ">", text) - text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\xEF\xBF\xBE]", "", text) + text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text) # Unicode U+FFFE text = re.sub("\ufffe", "", text) diff --git a/api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py b/api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py index c7a4265a954..2155342ab98 100644 --- a/api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py +++ b/api/tests/unit_tests/core/rag/cleaner/test_clean_processor.py @@ -22,6 +22,23 @@ class TestCleanProcessor: expected = "normalpadding" assert CleanProcessor.clean(text_with_ufffe, None) == expected + def test_clean_preserves_valid_extended_characters(self): + """Default cleaning must not strip valid printable characters. + + The invalid-symbol filter used to include the UTF-8 bytes of U+FFFE + (0xEF 0xBF 0xBE) inside a character class. On a decoded string those + bytes are the code points U+00EF, U+00BF and U+00BE, i.e. the valid + characters 'ï', '¿' and '¾', so words like "naïve" and Spanish + questions like "¿Cómo?" were being silently corrupted on ingest. + """ + assert CleanProcessor.clean("naïve", None) == "naïve" + assert CleanProcessor.clean("¿Cómo estás?", None) == "¿Cómo estás?" + assert CleanProcessor.clean("¾ cup sugar", None) == "¾ cup sugar" + assert CleanProcessor.clean("￾", None) == "￾" + + # The U+FFFE noncharacter is still stripped by its dedicated substitution. + assert CleanProcessor.clean("keep\ufffedrop", None) == "keepdrop" + def test_clean_with_none_process_rule(self): """Test cleaning with None process_rule - only default cleaning applied.""" text = "Hello<|World\x00" diff --git a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py index 5307da6d343..ef1b38f49d7 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py +++ b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py @@ -1372,6 +1372,19 @@ class TestIndexingRunnerDocumentCleaning: assert "\ufffe" not in result assert "Text with" in result + def test_filter_string_preserves_valid_extended_characters(self): + """filter_string must keep valid printable characters like 'ï', '¿', '¾'.""" + # Arrange + text = "naïve ¿Cómo? ¾ done" + + # Act + result = IndexingRunner.filter_string(text) + + # Assert + assert result == text + # The U+FFFE noncharacter is still stripped. + assert IndexingRunner.filter_string("keep\ufffedrop") == "keepdrop" + class TestIndexingRunnerSplitter: """Unit tests for text splitter configuration. From 7e6ba05464144839b3d333237b74f6cb79a21934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Mon, 27 Jul 2026 10:57:18 +0800 Subject: [PATCH 019/531] feat(api): expose app mode in webapp site response (#39607) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/web/human_input_form.py | 3 +- api/controllers/web/site.py | 6 ++- api/openapi/markdown/web-openapi.md | 7 ++++ .../controllers/web/test_site.py | 5 +++ .../controllers/web/test_human_input_form.py | 2 + .../unit_tests/controllers/web/test_site.py | 37 ++++++++++++++++++- .../contracts/generated/api/web/types.gen.ts | 11 ++++++ .../contracts/generated/api/web/zod.gen.ts | 15 ++++++++ 8 files changed, 83 insertions(+), 3 deletions(-) diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 775e802a70c..bd2b9a56efc 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -24,7 +24,7 @@ from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import RateLimiter, dump_response, extract_remote_ip, to_timestamp from models.account import TenantStatus -from models.model import App, Site +from models.model import App, AppMode, Site from repositories.factory import DifyAPIRepositoryFactory from services.feature_service import FeatureService from services.human_input_file_upload_service import HumanInputFileUploadService @@ -207,6 +207,7 @@ class HumanInputFormApi(Resource): site=WebAppSiteResponse.from_app_site( tenant=tenant, app_model=app_model, + mode=AppMode.value_of(app_model.mode), site=site, end_user_id=None, features=features, diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index f6c4af013a1..1d9a01dcf4c 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -14,7 +14,7 @@ from extensions.storage.storage_type import StorageType from fields.base import ResponseModel from libs.helper import build_icon_url from models.account import Tenant, TenantStatus -from models.model import App, EndUser, IconType, Site +from models.model import App, AppMode, EndUser, IconType, Site from services.feature_service import FeatureModel, FeatureService from services.file_service import FileService @@ -67,6 +67,7 @@ class WebAppCustomConfigResponse(ResponseModel): class WebAppSiteResponse(ResponseModel): app_id: str + mode: AppMode end_user_id: str | None = None enable_site: bool site: WebSiteResponse @@ -83,6 +84,7 @@ class WebAppSiteResponse(ResponseModel): *, tenant: Tenant, app_model: App, + mode: AppMode, site: Site, end_user_id: str | None, features: FeatureModel, @@ -109,6 +111,7 @@ class WebAppSiteResponse(ResponseModel): return cls( app_id=app_model.id, + mode=mode, end_user_id=end_user_id, enable_site=app_model.enable_site, site=site_response, @@ -167,6 +170,7 @@ class AppSiteApi(WebApiResource): return WebAppSiteResponse.from_app_site( tenant=tenant, app_model=app_model, + mode=AppMode.value_of(app_model.mode_compatible_with_agent_with_session(session=db.session())), site=site, end_user_id=end_user.id, features=features, diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 09c6842329c..c5fe79b60e5 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -965,6 +965,12 @@ Returns Server-Sent Events stream. | ---- | ---- | ----------- | -------- | | tool_icons | object | Tool icon metadata keyed by tool name | No | +#### AppMode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AppMode | string | | | + #### AppPermissionQuery | Name | Type | Description | Required | @@ -1646,6 +1652,7 @@ in form definition, or a variable while the workflow is running. | custom_config | [WebAppCustomConfigResponse](#webappcustomconfigresponse) | | No | | enable_site | boolean | | Yes | | end_user_id | string | | No | +| mode | [AppMode](#appmode) | | Yes | | model_config | [WebModelConfigResponse](#webmodelconfigresponse) | | No | | plan | string | | Yes | | site | [WebSiteResponse](#websiteresponse) | | Yes | diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index 7f4fd45d037..1e4d5aba030 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -97,6 +97,7 @@ class TestAppSiteApi: assert result["end_user_id"] == end_user.id assert result["plan"] == "basic" assert result["enable_site"] is True + assert result["mode"] == AppMode.CHAT @patch("controllers.web.site.FileService.get_file_presigned_url") @patch("controllers.web.site.FeatureService.get_features") @@ -178,6 +179,7 @@ class TestWebAppSiteResponse: response = WebAppSiteResponse.from_app_site( tenant=tenant, app_model=app_model, + mode=AppMode.CHAT, site=_site_model(app_id=app_model.id), end_user_id="eu-1", features=FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True), @@ -185,6 +187,7 @@ class TestWebAppSiteResponse: ) assert response.app_id == app_model.id + assert response.mode == AppMode.CHAT assert response.end_user_id == "eu-1" assert response.enable_site is True assert response.plan == "basic" @@ -209,6 +212,7 @@ class TestWebAppSiteResponse: response = WebAppSiteResponse.from_app_site( tenant=tenant, app_model=app_model, + mode=AppMode.CHAT, site=site, end_user_id=None, features=FeatureModel(can_replace_logo=False, webapp_copyright_enabled=True), @@ -236,6 +240,7 @@ class TestWebAppSiteResponse: response = WebAppSiteResponse.from_app_site( tenant=tenant, app_model=app_model, + mode=AppMode.CHAT, site=_site_model(app_id=app_model.id), end_user_id="eu-1", features=FeatureModel(can_replace_logo=True, webapp_copyright_enabled=True), diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 3408e3049d1..042cb30a98e 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -163,6 +163,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask, dat assert body["expiration_time"] == int(expiration_time.timestamp()) assert body["site"] == { "app_id": app_model.id, + "mode": "chat", "end_user_id": None, "enable_site": True, "site": { @@ -383,6 +384,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F assert body["expiration_time"] == int(expiration_time.timestamp()) assert body["site"] == { "app_id": app_model.id, + "mode": "chat", "end_user_id": None, "enable_site": True, "site": { diff --git a/api/tests/unit_tests/controllers/web/test_site.py b/api/tests/unit_tests/controllers/web/test_site.py index 1c2a403994f..f7a44afad6a 100644 --- a/api/tests/unit_tests/controllers/web/test_site.py +++ b/api/tests/unit_tests/controllers/web/test_site.py @@ -3,7 +3,42 @@ from unittest.mock import MagicMock, patch from configs import dify_config from controllers.web import site as site_module from extensions.storage.storage_type import StorageType -from models.model import IconType, Site +from models.model import AppMode, IconType, Site +from services.feature_service import FeatureModel + + +def test_app_site_api_returns_legacy_agent_compatible_mode() -> None: + app_model = MagicMock() + app_model.id = "app-id" + app_model.tenant_id = "tenant-id" + app_model.tenant = MagicMock(id="tenant-id", status="normal") + app_model.mode_compatible_with_agent_with_session.return_value = AppMode.AGENT_CHAT + end_user = MagicMock(id="end-user-id") + site = MagicMock(spec=Site) + response = MagicMock() + response.model_dump.return_value = {"mode": AppMode.AGENT_CHAT} + + with ( + patch.object(site_module, "db") as mock_db, + patch.object(site_module.FeatureService, "get_features", return_value=FeatureModel(can_replace_logo=False)), + patch.object(site_module, "_build_site_icon_url", return_value=None), + patch.object(site_module.WebAppSiteResponse, "from_app_site", return_value=response) as mock_from_app_site, + ): + mock_db.session.scalar.return_value = site + result = site_module.AppSiteApi().get(app_model, end_user) + + assert result["mode"] == AppMode.AGENT_CHAT + app_model.mode_compatible_with_agent_with_session.assert_called_once_with(session=mock_db.session()) + mock_from_app_site.assert_called_once_with( + tenant=app_model.tenant, + app_model=app_model, + mode=AppMode.AGENT_CHAT, + site=site, + end_user_id=end_user.id, + features=FeatureModel(can_replace_logo=False), + can_replace_logo=False, + icon_url=None, + ) def test_build_site_icon_url_uses_s3_presigned_url() -> None: diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index d832ca3d98a..8812b164b9e 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -43,6 +43,16 @@ export type AppMetaResponse = { } } +export type AppMode = + | 'advanced-chat' + | 'agent' + | 'agent-chat' + | 'channel' + | 'chat' + | 'completion' + | 'rag-pipeline' + | 'workflow' + export type AppPermissionQuery = { appId: string } @@ -575,6 +585,7 @@ export type WebAppSiteResponse = { custom_config?: WebAppCustomConfigResponse | null enable_site: boolean end_user_id?: string | null + mode: AppMode model_config?: WebModelConfigResponse | null plan: string site: WebSiteResponse diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 0e1cd8fc76d..59eed414d4b 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -39,6 +39,20 @@ export const zAppMetaResponse = z.object({ tool_icons: z.record(z.string(), z.unknown()).optional(), }) +/** + * AppMode + */ +export const zAppMode = z.enum([ + 'advanced-chat', + 'agent', + 'agent-chat', + 'channel', + 'chat', + 'completion', + 'rag-pipeline', + 'workflow', +]) + /** * AppPermissionQuery */ @@ -884,6 +898,7 @@ export const zWebAppSiteResponse = z.object({ custom_config: zWebAppCustomConfigResponse.nullish(), enable_site: z.boolean(), end_user_id: z.string().nullish(), + mode: zAppMode, model_config: zWebModelConfigResponse.nullish(), plan: z.string(), site: zWebSiteResponse, From 34e6e5a04917101dcbfd6359aac2441effaa592b Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:01:24 +0800 Subject: [PATCH 020/531] refactor(web): simplify Dify logo theming (#39580) --- oxlint-suppressions.json | 5 -- .../form/[token]/__tests__/form.spec.tsx | 2 +- .../form/[token]/branding-footer.tsx | 4 +- web/app/account/(commonLayout)/header.tsx | 2 +- .../chat/chat-with-history/sidebar/index.tsx | 4 +- .../chat/embedded-chatbot/header/index.tsx | 4 +- .../base/chat/embedded-chatbot/index.tsx | 4 +- .../base/logo/__tests__/dify-logo.spec.tsx | 16 +++++ web/app/components/base/logo/dify-logo.tsx | 67 +++++++++---------- web/app/components/billing/pricing/header.tsx | 4 +- .../components/powered-by-brand.tsx | 4 +- .../components/header/account-about/index.tsx | 4 +- web/app/components/main-nav/index.tsx | 2 +- .../plugins/marketplace/description/index.tsx | 4 +- .../text-generation-sidebar.tsx | 4 +- web/app/device/_header.tsx | 7 +- .../education-apply/education-apply-page.tsx | 4 +- web/app/signin/_header.tsx | 8 +-- web/public/logo/logo-monochrome-white.svg | 12 ---- 19 files changed, 75 insertions(+), 86 deletions(-) create mode 100644 web/app/components/base/logo/__tests__/dify-logo.spec.tsx delete mode 100644 web/public/logo/logo-monochrome-white.svg diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index f98f7a16fa9..ba83af1b8c9 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -1691,11 +1691,6 @@ "count": 1 } }, - "web/app/components/base/logo/dify-logo.tsx": { - "react/only-export-components": { - "count": 2 - } - }, "web/app/components/base/markdown-blocks/__tests__/paragraph.spec.tsx": { "jsx_a11y/anchor-is-valid": { "count": 1 diff --git a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx index 147af155fff..01ce7d01c43 100644 --- a/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx +++ b/web/app/(humanInputLayout)/form/[token]/__tests__/form.spec.tsx @@ -120,7 +120,7 @@ vi.mock('@/app/components/base/loading', () => ({ vi.mock('@/app/components/base/logo/dify-logo', () => ({ __esModule: true, - default: () =>
dify-logo
, + DifyLogo: () =>
dify-logo
, })) vi.mock('@/app/components/base/app-icon', () => ({ diff --git a/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx b/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx index 8c26efbfdc5..739c97e09dd 100644 --- a/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx +++ b/web/app/(humanInputLayout)/form/[token]/branding-footer.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' type BrandingFooterProps = { removeWebappBrand?: boolean @@ -20,7 +20,7 @@ const BrandingFooter = ({ removeWebappBrand, replaceWebappLogo }: BrandingFooter {replaceWebappLogo ? ( logo ) : ( - + )}
diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index 18cfb9763fc..f056bb32601 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -3,7 +3,7 @@ import { Button } from '@langgenius/dify-ui/button' import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { useRouter } from '@/next/navigation' diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index f62ec1d4ab5..609a6f76198 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -17,7 +17,7 @@ import ActionButton from '@/app/components/base/action-button' import AppIcon from '@/app/components/base/app-icon' import List from '@/app/components/base/chat/chat-with-history/sidebar/list' import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useChatWithHistoryContext } from '../context' @@ -177,7 +177,7 @@ const Sidebar = ({ isPanel }: Props) => { className="block h-5 w-auto" /> ) : ( - + )}
)} diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index ada6a182a43..9d64d7fa235 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown' import Divider from '@/app/components/base/divider' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { isClient } from '@/utils/client' import { useEmbeddedChatbotContext } from '../context' @@ -105,7 +105,7 @@ const Header: FC = ({ className="block h-5 w-auto" /> ) : ( - + )}
)} diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx index d2b23db31a7..d67dc55f1c6 100644 --- a/web/app/components/base/chat/embedded-chatbot/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/index.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' import Header from '@/app/components/base/chat/embedded-chatbot/header' import Loading from '@/app/components/base/loading' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -95,7 +95,7 @@ const Chatbot = () => { className="block h-5 w-auto" /> ) : ( - + )}
)} diff --git a/web/app/components/base/logo/__tests__/dify-logo.spec.tsx b/web/app/components/base/logo/__tests__/dify-logo.spec.tsx new file mode 100644 index 00000000000..95b6dad028c --- /dev/null +++ b/web/app/components/base/logo/__tests__/dify-logo.spec.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react' +import { DifyLogo } from '../dify-logo' + +describe('DifyLogo', () => { + it('uses the provided alternative text as its accessible name', () => { + const { container, rerender } = render() + + expect(screen.getByRole('img', { name: 'Dify' })).toHaveAttribute('src', '/logo/logo.svg') + + rerender() + + const decorativeLogo = container.querySelector('img') + expect(decorativeLogo).toHaveAttribute('alt', '') + expect(screen.queryByRole('img')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/logo/dify-logo.tsx b/web/app/components/base/logo/dify-logo.tsx index 99d479fa156..f94b9112ec1 100644 --- a/web/app/components/base/logo/dify-logo.tsx +++ b/web/app/components/base/logo/dify-logo.tsx @@ -1,47 +1,44 @@ -'use client' -import type { FC } from 'react' +import type { VariantProps } from 'class-variance-authority' +import type { ComponentProps } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import useTheme from '@/hooks/use-theme' +import { cva } from 'class-variance-authority' import { basePath } from '@/utils/var' -export type LogoStyle = 'default' | 'monochromeWhite' +const difyLogoVariants = cva( + 'block object-contain [html[data-theme=dark]_&]:brightness-0 [html[data-theme=dark]_&]:invert', + { + variants: { + size: { + small: 'h-4 w-9', + medium: 'h-[22px] w-12', + large: 'h-7 w-16', + }, + }, + defaultVariants: { + size: 'medium', + }, + }, +) -export const logoPathMap: Record = { - default: '/logo/logo.svg', - monochromeWhite: '/logo/logo-monochrome-white.svg', -} +export type DifyLogoProps = Omit< + ComponentProps<'img'>, + 'alt' | 'height' | 'size' | 'src' | 'width' +> & + VariantProps & { + alt: string + } -export type LogoSize = 'large' | 'medium' | 'small' - -export const logoSizeMap: Record = { - large: 'w-16 h-7', - medium: 'w-12 h-[22px]', - small: 'w-9 h-4', -} - -type DifyLogoProps = { - style?: LogoStyle - size?: LogoSize - className?: string - alt?: string -} - -const DifyLogo: FC = ({ - style = 'default', - size = 'medium', - className, - alt = 'Dify', -}) => { - const { theme } = useTheme() - const themedStyle = theme === 'dark' && style === 'default' ? 'monochromeWhite' : style +export function DifyLogo({ alt, className, size, ...props }: DifyLogoProps) { + const classes = cn(difyLogoVariants({ size, className })) return ( {alt} ) } - -export default DifyLogo diff --git a/web/app/components/billing/pricing/header.tsx b/web/app/components/billing/pricing/header.tsx index d436754b5fa..c6e1ed1d0dc 100644 --- a/web/app/components/billing/pricing/header.tsx +++ b/web/app/components/billing/pricing/header.tsx @@ -3,7 +3,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' -import DifyLogo from '../../base/logo/dify-logo' +import { DifyLogo } from '../../base/logo/dify-logo' import styles from './header.module.css' type HeaderProps = { @@ -18,7 +18,7 @@ const Header = ({ onClose }: HeaderProps) => {
) : ( - + )} ) diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx index e4c497c5f3b..a9d326c5f89 100644 --- a/web/app/components/header/account-about/index.tsx +++ b/web/app/components/header/account-about/index.tsx @@ -6,7 +6,7 @@ import { RiCloseLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import { useTranslation } from 'react-i18next' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' @@ -48,7 +48,7 @@ export default function AccountAbout({ langGeniusVersionInfo, onCancel }: IAccou alt="logo" /> ) : ( - + )}
diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 45127955827..ee077f1d8ac 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -7,7 +7,7 @@ import { useAtomValue } from 'jotai' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import EnvNav from '@/app/components/header/env-nav' import StepByStepTourMount from '@/app/components/step-by-step-tour/mount' import { langGeniusVersionInfoAtom } from '@/context/version-state' diff --git a/web/app/components/plugins/marketplace/description/index.tsx b/web/app/components/plugins/marketplace/description/index.tsx index c28d2b28ebd..05fb6b16dad 100644 --- a/web/app/components/plugins/marketplace/description/index.tsx +++ b/web/app/components/plugins/marketplace/description/index.tsx @@ -5,7 +5,7 @@ import { motion, useMotionValue, useSpring, useTransform } from 'motion/react' import { useEffect, useLayoutEffect, useRef } from 'react' import { useLocale, useTranslation } from '#i18n' import Divider from '@/app/components/base/divider' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { SubmitRequestDropdown } from '@/app/components/plugins/plugin-page/nav-operations' import PluginTypeSwitch from '../plugin-type-switch' import SearchBoxWrapper from '../search-box/search-box-wrapper' @@ -231,7 +231,7 @@ const Description = ({
- + {tCommon(($) => $['mainNav.marketplace'])} diff --git a/web/app/components/share/text-generation/text-generation-sidebar.tsx b/web/app/components/share/text-generation/text-generation-sidebar.tsx index 36fb5769061..d5fa0831f8d 100644 --- a/web/app/components/share/text-generation/text-generation-sidebar.tsx +++ b/web/app/components/share/text-generation/text-generation-sidebar.tsx @@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next' import SavedItems from '@/app/components/app/text-generate/saved-items' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' -import DifyLogo from '@/app/components/base/logo/dify-logo' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { appDefaultIconBackground } from '@/config' import { AccessMode } from '@/models/access-control' import MenuDropdown from './menu-dropdown' @@ -223,7 +223,7 @@ const TextGenerationSidebar: FC = ({ ) : customConfig?.replace_webapp_logo ? ( logo ) : ( - + )}
)} diff --git a/web/app/device/_header.tsx b/web/app/device/_header.tsx index f5990151629..fe555ad4314 100644 --- a/web/app/device/_header.tsx +++ b/web/app/device/_header.tsx @@ -1,6 +1,7 @@ 'use client' import { useSuspenseQuery } from '@tanstack/react-query' import Divider from '@/app/components/base/divider' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import LocaleMenu from '@/app/signin/_locale-menu' import { useLocale } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -8,10 +9,6 @@ import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' import dynamic from '@/next/dynamic' -const DifyLogo = dynamic(() => import('@/app/components/base/logo/dify-logo'), { - ssr: false, - loading: () =>
, -}) const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector'), { ssr: false, loading: () =>
, @@ -30,7 +27,7 @@ const Header = () => { alt="logo" /> ) : ( - + )}
{ }} >
- +
diff --git a/web/app/signin/_header.tsx b/web/app/signin/_header.tsx index 44a8cd4364a..ed824b6a2e4 100644 --- a/web/app/signin/_header.tsx +++ b/web/app/signin/_header.tsx @@ -1,6 +1,7 @@ 'use client' import { useSuspenseQuery } from '@tanstack/react-query' import Divider from '@/app/components/base/divider' +import { DifyLogo } from '@/app/components/base/logo/dify-logo' import { useLocale } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { setLocaleOnClient } from '@/i18n-config' @@ -8,11 +9,6 @@ import { languages } from '@/i18n-config/language' import dynamic from '@/next/dynamic' import LocaleMenu from './_locale-menu' -// Avoid rendering the logo and theme selector on the server -const DifyLogo = dynamic(() => import('@/app/components/base/logo/dify-logo'), { - ssr: false, - loading: () =>
, -}) const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector'), { ssr: false, loading: () =>
, @@ -31,7 +27,7 @@ const Header = () => { alt="logo" /> ) : ( - + )}
- - - - - - - - - - - From e4e99d198b5461ed43c97934832a035672956686 Mon Sep 17 00:00:00 2001 From: CYJ1226 <68060964+CYJ1226@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:21:26 +0900 Subject: [PATCH 021/531] fix: preserve spaces during recursive text splitting (#39498) --- api/core/rag/splitter/fixed_text_splitter.py | 4 ++-- .../core/rag/splitter/test_text_splitter.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/api/core/rag/splitter/fixed_text_splitter.py b/api/core/rag/splitter/fixed_text_splitter.py index 98ba4d7fcc7..687f544e874 100644 --- a/api/core/rag/splitter/fixed_text_splitter.py +++ b/api/core/rag/splitter/fixed_text_splitter.py @@ -91,8 +91,8 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter) splits = re.split(r" +", text) else: splits = text.split(separator) - if self._keep_separator: - splits = [s + separator for s in splits[:-1]] + splits[-1:] + if self._keep_separator: + splits = [s + separator for s in splits[:-1]] + splits[-1:] else: splits = list(text) if separator == "\n": diff --git a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py index 12117241b5d..7a0726d3a9c 100644 --- a/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py +++ b/api/tests/unit_tests/core/rag/splitter/test_text_splitter.py @@ -952,6 +952,21 @@ class TestFixedRecursiveCharacterTextSplitter: assert "word1" in combined assert "word2" in combined + def test_preserves_spaces_when_recursively_splitting_long_paragraph(self): + """Ensure recursive space splitting preserves word boundaries.""" + text = "여름철에는 항상 기상상황에 주목하며 주변 사람들과 함께 정보를 공유합니다." + splitter = FixedRecursiveCharacterTextSplitter( + fixed_separator="\n\n", + chunk_size=20, + chunk_overlap=0, + keep_separator=True, + ) + + result = splitter.split_text(text) + + assert len(result) > 1 + assert " ".join(result) == text + def test_character_level_splitting(self): """Test character-level splitting when no separator works.""" text = "verylongwordwithoutspaces" From d563d6e7df2c124d1bcbda7ab07cd7dba84119cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:56:57 +0800 Subject: [PATCH 022/531] chore: bump pypdf from 6.10.2 to 6.14.2 in /dify-agent (#39502) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dify-agent/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index c355ba99dca..d38f141e683 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -2740,11 +2740,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.10.2" +version = "6.14.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/3f/9f2167401c2e94833ca3b69535bad89e533b5de75fefe4197a2c224baec2/pypdf-6.10.2.tar.gz", hash = "sha256:7d09ce108eff6bf67465d461b6ef352dcb8d84f7a91befc02f904455c6eea11d", size = 5315679, upload-time = "2026-04-15T16:37:36.978Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/d6/1d5c60cc17bbdf37c1552d9c03862fc6d32c5836732a0415b2d637edc2d0/pypdf-6.10.2-py3-none-any.whl", hash = "sha256:aa53be9826655b51c96741e5d7983ca224d898ac0a77896e64636810517624aa", size = 336308, upload-time = "2026-04-15T16:37:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" }, ] [[package]] From cc01189966c83870d477e2edd4ba7b57bf356c85 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Mon, 27 Jul 2026 12:05:38 +0800 Subject: [PATCH 023/531] fix(agent): expose publish state from composer (#39615) --- api/controllers/console/agent/roster.py | 5 ----- api/fields/agent_fields.py | 1 + api/openapi/markdown/console-openapi.md | 2 +- api/services/agent/composer_service.py | 1 + .../console/agent/test_agent_controllers.py | 21 +++++++++---------- .../services/agent/test_agent_services.py | 19 +++++++++++++---- .../agent-v2/agent-edit.steps.ts | 8 +++---- .../agent-v2/publish.steps.ts | 8 +++---- .../generated/api/console/agent/types.gen.ts | 3 +-- .../generated/api/console/agent/zod.gen.ts | 3 +-- .../configure/components/composer-session.tsx | 2 +- web/service/client.spec.ts | 2 +- 12 files changed, 39 insertions(+), 36 deletions(-) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 60fb018915b..ea313ff37aa 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -257,7 +257,6 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite): debug_conversation_has_messages: bool = False debug_conversation_message_count: int = 0 role: str | None = None - active_config_is_published: bool = False class AgentDebugConversationRefreshResponse(BaseModel): @@ -410,10 +409,6 @@ def _serialize_agent_app_detail( payload["debug_conversation_has_messages"] = message_count > 0 payload["debug_conversation_message_count"] = message_count payload["role"] = agent.role or "" - payload["active_config_is_published"] = roster_service.active_config_is_published( - tenant_id=app_model.tenant_id, - agent=agent, - ) return payload diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 50105273e1c..91e22eb5b36 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -383,6 +383,7 @@ class AgentAppComposerResponse(ResponseModel): variant: Literal[ComposerVariant.AGENT_APP] agent: AgentComposerAgentResponse active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None + active_config_is_published: bool draft: AgentConfigDraftSummaryResponse | None = None agent_soul: AgentSoulConfig save_options: list[ComposerSaveStrategy] diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 367ba473e9e..34478b71b7c 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -13243,6 +13243,7 @@ Model class for AI model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| active_config_is_published | boolean | | Yes | | active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No | | agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes | | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes | @@ -13282,7 +13283,6 @@ Model class for AI model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | -| active_config_is_published | boolean | | No | | api_base_url | string | | No | | app_id | string | | No | | backing_app_id | string | | No | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 96f0b8dde23..aba9e9d8fc8 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -405,6 +405,7 @@ class AgentComposerService: "variant": ComposerVariant.AGENT_APP.value, "agent": cls._serialize_agent(agent), "active_config_snapshot": cls._serialize_version(version), + "active_config_is_published": bool(agent.active_config_snapshot_id and agent.active_config_is_published), "draft": cls._serialize_draft(draft), "agent_soul": draft.config_snapshot_dict, "save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value], diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 14628b2614d..60c33073408 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -115,6 +115,7 @@ def _agent_app_composer_response() -> dict: "active_config_snapshot_id": "version-1", }, "active_config_snapshot": _version_response(), + "active_config_is_published": True, "agent_soul": {}, "save_options": ["save_to_current_version"], } @@ -376,7 +377,7 @@ def test_agent_app_list_and_create_use_agent_route( assert created["app_id"] == "app-created" assert created["debug_conversation_id"] == "debug-conversation-created" assert created["role"] == "Created role" - assert created["active_config_is_published"] is False + assert "active_config_is_published" not in created assert "bound_agent_id" not in created create_call = cast(dict[str, object], captured["create"]) create_params = cast(Any, create_call["params"]) @@ -487,7 +488,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert detail["debug_conversation_has_messages"] is True assert detail["debug_conversation_message_count"] == 2 assert detail["role"] == "Resolved role" - assert detail["active_config_is_published"] is False + assert "active_config_is_published" not in detail assert "bound_agent_id" not in detail assert captured["get_app"] == {"app": app_model, "session": session} with app.test_request_context( @@ -502,7 +503,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert updated["debug_conversation_has_messages"] is True assert updated["debug_conversation_message_count"] == 2 assert updated["role"] == "Resolved role" - assert updated["active_config_is_published"] is False + assert "active_config_is_published" not in updated assert "bound_agent_id" not in updated update_call = cast(dict[str, object], captured["update"]) assert update_call["app"] is app_model @@ -845,9 +846,6 @@ def test_agent_app_update_allows_empty_role(app: Flask, monkeypatch: pytest.Monk monkeypatch.setattr( roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0 ) - monkeypatch.setattr( - roster_controller.AgentRosterService, "active_config_is_published", lambda _self, **kwargs: False - ) monkeypatch.setattr( roster_controller.FeatureService, "get_system_features", @@ -1299,13 +1297,14 @@ def test_agent_composer_routes_resolve_app_from_agent_id( composer_controller.AgentComposerService, "collect_validation_findings", collect_validation_findings ) monkeypatch.setattr(composer_controller.AgentComposerService, "get_agent_app_candidates", get_agent_app_candidates) - assert unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)["variant"] == "agent_app" + composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id) + assert composer["variant"] == "agent_app" + assert composer["active_config_is_published"] is True assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id with app.test_request_context(json=payload): - assert ( - unwrap(AgentComposerApi.put)(AgentComposerApi(), MagicMock(), "tenant-1", account_id, agent_id)["variant"] - == "agent_app" - ) + saved_composer = unwrap(AgentComposerApi.put)(AgentComposerApi(), MagicMock(), "tenant-1", account_id, agent_id) + assert saved_composer["variant"] == "agent_app" + assert saved_composer["active_config_is_published"] is True assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id assert unwrap(AgentComposerValidateApi.post)(AgentComposerValidateApi(), MagicMock(), "tenant-1", agent_id) == { "result": "success", diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index c1946968e67..f881e8770dd 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -548,6 +548,7 @@ def test_load_agent_app_composer_exposes_draft_save_only(monkeypatch: pytest.Mon agent = SimpleNamespace( id="agent-1", active_config_snapshot_id="version-1", + active_config_is_published=True, updated_by="account-1", created_by="account-1", app_id="app-1", @@ -567,6 +568,7 @@ def test_load_agent_app_composer_exposes_draft_save_only(monkeypatch: pytest.Mon result = AgentComposerService.load_agent_app_composer(session=session, tenant_id="tenant-1", app_id="app-1") assert result["save_options"] == [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value] + assert result["active_config_is_published"] is True def test_save_agent_app_composer_rejects_version_save_strategy(): @@ -610,7 +612,11 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey 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}) + monkeypatch.setattr( + AgentComposerService, + "load_agent_composer", + lambda **kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published}, + ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, @@ -628,7 +634,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey ) assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} - assert result == {"loaded": True} + assert result == {"loaded": True, "active_config_is_published": False} assert saved["draft_type"] == AgentConfigDraftType.DRAFT assert saved["agent_soul"].model_dump(mode="json") == _agent_soul_with_model().model_dump(mode="json") assert agent.active_config_is_published is False @@ -657,7 +663,11 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps lambda **_kwargs: 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}) + monkeypatch.setattr( + AgentComposerService, + "load_agent_composer", + lambda **_kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published}, + ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, @@ -666,7 +676,7 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps } ) - AgentComposerService.save_agent_app_composer( + result = AgentComposerService.save_agent_app_composer( session=session, tenant_id="tenant-1", app_id="app-1", @@ -675,6 +685,7 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps ) assert agent.active_config_is_published is True + assert result["active_config_is_published"] is True assert fake_session.flushes >= 1 diff --git a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts index bce34d4ff54..06eb5291323 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -31,6 +31,7 @@ const getComposerInheritanceSnapshot = async (world: DifyWorld, agentId: string) const knowledgeSets = asArray(asRecord(soul.knowledge).sets) return { + activeConfigIsPublished: draft.active_config_is_published, fileNames: files .map((file) => asString(asRecord(file).name)) .filter(Boolean) @@ -189,8 +190,7 @@ Then( ) const client = this.getConsoleClient() - const [sourceDetail, duplicatedDetail, sourceSnapshot, duplicatedSnapshot] = await Promise.all([ - client.agent.byAgentId.get({ params: { agent_id: sourceAgent.id } }), + const [duplicatedDetail, sourceSnapshot, duplicatedSnapshot] = await Promise.all([ client.agent.byAgentId.get({ params: { agent_id: duplicatedAgentId } }), getComposerInheritanceSnapshot(this, sourceAgent.id), getComposerInheritanceSnapshot(this, duplicatedAgentId), @@ -198,9 +198,7 @@ Then( expect(duplicatedDetail.id).toBe(duplicatedAgentId) expect(duplicatedDetail.name).toBe(this.lastCreatedAgentName) - expect(duplicatedDetail.active_config_is_published).toBe( - sourceDetail.active_config_is_published, - ) + expect(duplicatedSnapshot.activeConfigIsPublished).toBe(sourceSnapshot.activeConfigIsPublished) expect(duplicatedSnapshot.model).toEqual({ name: stableModel.name, provider: stableModel.provider, diff --git a/e2e/features/step-definitions/agent-v2/publish.steps.ts b/e2e/features/step-definitions/agent-v2/publish.steps.ts index dcfaaf69e5f..db47cf0225b 100644 --- a/e2e/features/step-definitions/agent-v2/publish.steps.ts +++ b/e2e/features/step-definitions/agent-v2/publish.steps.ts @@ -32,10 +32,10 @@ Then('the Agent v2 draft should remain unpublished', async function (this: DifyW .poll( async () => { const agentId = getCurrentAgentId(this) - const agent = await this.getConsoleClient().agent.byAgentId.get({ + const composer = await this.getConsoleClient().agent.byAgentId.composer.get({ params: { agent_id: agentId }, }) - return agent.active_config_is_published + return composer.active_config_is_published }, { timeout: 30_000 }, ) @@ -55,10 +55,10 @@ Then('the Agent v2 draft should be published and up to date', async function (th await expect(page.getByText('Up to date')).toBeVisible() await expect .poll(async () => { - const agent = await this.getConsoleClient().agent.byAgentId.get({ + const composer = await this.getConsoleClient().agent.byAgentId.composer.get({ params: { agent_id: agentId }, }) - return agent.active_config_is_published + return composer.active_config_is_published }) .toBe(true) }) diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 98fda2ccab9..0a2bae65ac1 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -23,7 +23,6 @@ export type AgentAppCreatePayload = { export type AgentAppDetailWithSite = { access_mode?: string | null - active_config_is_published?: boolean api_base_url?: string | null app_id?: string | null backing_app_id?: string | null @@ -169,6 +168,7 @@ export type SuggestedQuestionsResponse = { } export type AgentAppComposerResponse = { + active_config_is_published: boolean active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null agent: AgentComposerAgentResponse agent_soul: AgentSoulConfig @@ -1892,7 +1892,6 @@ export type AgentAppPaginationWritable = { export type AgentAppDetailWithSiteWritable = { access_mode?: string | null - active_config_is_published?: boolean api_base_url?: string | null app_id?: string | null backing_app_id?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 74d05d36775..dfa6917bfb7 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -374,7 +374,6 @@ export const zWorkflowPartial = z.object({ */ export const zAgentAppDetailWithSite = z.object({ access_mode: z.string().nullish(), - active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), backing_app_id: z.string().nullish(), @@ -2494,6 +2493,7 @@ export const zComposerSavePayload = z.object({ * AgentAppComposerResponse */ export const zAgentAppComposerResponse = z.object({ + active_config_is_published: z.boolean(), active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), agent: zAgentComposerAgentResponse, agent_soul: zAgentSoulConfig, @@ -2730,7 +2730,6 @@ export const zAppDetailSiteResponseWritable = z.object({ */ export const zAgentAppDetailWithSiteWritable = z.object({ access_mode: z.string().nullish(), - active_config_is_published: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), backing_app_id: z.string().nullish(), diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx index 0874ad7ac48..c0db1aa220c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx @@ -447,7 +447,7 @@ function AgentConfigurePageComposerContent({ leftPanel={ { const createAgent = (overrides: Partial = {}): AgentMutationResponse => ({ ...overrides, - active_config_is_published: overrides.active_config_is_published ?? false, debug_conversation_has_messages: overrides.debug_conversation_has_messages ?? false, debug_conversation_message_count: overrides.debug_conversation_message_count ?? 0, enable_api: overrides.enable_api ?? true, @@ -125,6 +124,7 @@ const createAgent = (overrides: Partial = {}): AgentMutat const createComposerState = ( overrides: Partial = {}, ): AgentComposerMutationResponse => ({ + active_config_is_published: false, active_config_snapshot: { id: 'snapshot-1', version: 1, From 2962a7ea93dd1243f7ea8864c3c5ecf4bafecf6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:32:09 +0900 Subject: [PATCH 024/531] chore(deps): bump the github-actions-dependencies group with 6 updates (#39603) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/api-tests.yml | 12 ++++++------ .github/workflows/autofix.yml | 6 +++--- .github/workflows/build-push.yml | 4 ++-- .github/workflows/cli-e2e.yml | 12 ++++++------ .github/workflows/cli-edge.yml | 2 +- .github/workflows/cli-release.yml | 4 ++-- .github/workflows/cli-smoke.yml | 2 +- .github/workflows/cli-tests.yml | 2 +- .github/workflows/db-migration-test.yml | 8 ++++---- .github/workflows/hotfix-cherry-pick.yml | 2 +- .github/workflows/labeler.yml | 2 +- .github/workflows/main-ci.yml | 2 +- .github/workflows/post-merge.yml | 2 +- .github/workflows/pyrefly-diff.yml | 4 ++-- .github/workflows/pyrefly-type-coverage-comment.yml | 4 ++-- .github/workflows/pyrefly-type-coverage.yml | 4 ++-- .github/workflows/sandbox-runtime-tests.yml | 6 +++--- .github/workflows/style.yml | 10 +++++----- .github/workflows/tool-test-sdks.yaml | 2 +- .github/workflows/translate-i18n-claude.yml | 4 ++-- .github/workflows/trigger-i18n-sync.yml | 2 +- .github/workflows/vdb-tests-full.yml | 4 ++-- .github/workflows/vdb-tests.yml | 4 ++-- .github/workflows/web-e2e.yml | 4 ++-- .github/workflows/web-tests.yml | 8 ++++---- 25 files changed, 58 insertions(+), 58 deletions(-) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index e8bcd20cf66..866d27fe822 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -29,13 +29,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -88,13 +88,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -139,13 +139,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index d05ebed87d5..e5fed0398a4 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check Docker Compose inputs if: github.event_name != 'merge_group' @@ -84,12 +84,12 @@ jobs: dify-agent/pyproject.toml dify-agent/uv.lock - if: github.event_name != 'merge_group' - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - if: github.event_name != 'merge_group' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Generate Docker Compose if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 545495ca712..575b8aadf2a 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -97,7 +97,7 @@ jobs: echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Login to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ env.DOCKERHUB_USER }} password: ${{ env.DOCKERHUB_TOKEN }} @@ -199,7 +199,7 @@ jobs: merge-multiple: true - name: Login to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 with: username: ${{ env.DOCKERHUB_USER }} password: ${{ env.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index b99853972dd..d89580ffa66 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -79,7 +79,7 @@ jobs: ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -123,7 +123,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -170,7 +170,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -233,7 +233,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -295,7 +295,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false @@ -351,7 +351,7 @@ jobs: shell: bash steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false diff --git a/.github/workflows/cli-edge.yml b/.github/workflows/cli-edge.yml index d4d789d0b14..4c8a0ff9e72 100644 --- a/.github/workflows/cli-edge.yml +++ b/.github/workflows/cli-edge.yml @@ -23,7 +23,7 @@ jobs: working-directory: ./cli steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 788f0bfe21c..0f84230639b 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -35,7 +35,7 @@ jobs: dify_tag: ${{ steps.resolve.outputs.dify_tag }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -98,7 +98,7 @@ jobs: DIFY_TAG: ${{ needs.validate.outputs.dify_tag }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 1 diff --git a/.github/workflows/cli-smoke.yml b/.github/workflows/cli-smoke.yml index a46d93c2ac8..3437d7a52c3 100644 --- a/.github/workflows/cli-smoke.yml +++ b/.github/workflows/cli-smoke.yml @@ -24,7 +24,7 @@ jobs: shell: bash steps: - name: Checkout cli ref - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.cli_ref || github.ref }} persist-credentials: false diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 3638f79cae6..39fb7647177 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index ae3d4f67c48..38b8b9540c5 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -13,13 +13,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: "3.12" @@ -63,13 +63,13 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/hotfix-cherry-pick.yml b/.github/workflows/hotfix-cherry-pick.yml index 55a1ac5f5ed..aff6c1c3664 100644 --- a/.github/workflows/hotfix-cherry-pick.yml +++ b/.github/workflows/hotfix-cherry-pick.yml @@ -24,7 +24,7 @@ jobs: name: Require cherry-pick provenance runs-on: depot-ubuntu-24.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 65c972522e3..181fc2db007 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -9,6 +9,6 @@ jobs: pull-requests: write runs-on: depot-ubuntu-24.04 steps: - - uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0 + - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0 with: sync-labels: true diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index aec8514a69d..1360d698c76 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -47,7 +47,7 @@ jobs: migration-changed: ${{ steps.changes.outputs.migration }} sandbox-runtime-changed: ${{ steps.changes.outputs.sandbox-runtime }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: changes with: diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index c7ee9850b08..2981fc44225 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -18,7 +18,7 @@ jobs: outputs: external-e2e-changed: ${{ steps.changes.outputs.external_e2e }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: changes with: diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index b8ed10612c4..27b04f030e1 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -17,12 +17,12 @@ jobs: pull-requests: write steps: - name: Checkout PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage-comment.yml b/.github/workflows/pyrefly-type-coverage-comment.yml index 8fd1e0f788e..eacf485c7a1 100644 --- a/.github/workflows/pyrefly-type-coverage-comment.yml +++ b/.github/workflows/pyrefly-type-coverage-comment.yml @@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python & UV - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage.yml b/.github/workflows/pyrefly-type-coverage.yml index fb223342701..19a2e18d48b 100644 --- a/.github/workflows/pyrefly-type-coverage.yml +++ b/.github/workflows/pyrefly-type-coverage.yml @@ -17,12 +17,12 @@ jobs: pull-requests: write steps: - name: Checkout PR branch - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true diff --git a/.github/workflows/sandbox-runtime-tests.yml b/.github/workflows/sandbox-runtime-tests.yml index 7e36067446c..3cdb68a2f85 100644 --- a/.github/workflows/sandbox-runtime-tests.yml +++ b/.github/workflows/sandbox-runtime-tests.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -45,7 +45,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -72,7 +72,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index ec7f5779083..34094ad6a2b 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 @@ -45,7 +45,7 @@ jobs: - name: Setup UV and Python if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: false python-version: "3.12" @@ -93,7 +93,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -144,7 +144,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -186,7 +186,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index d474396a300..2d0133131eb 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -24,7 +24,7 @@ jobs: working-directory: sdks/nodejs-client steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 77702ffbaa9..999b3501cf1 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@af0559ee4f514d1ef21826982bed13f7edc3c35e # v1.0.178 + uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1.0.183 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml index ad2a0675afa..6cb096562ff 100644 --- a/.github/workflows/trigger-i18n-sync.yml +++ b/.github/workflows/trigger-i18n-sync.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index 27923401e7c..0a33cc7beb0 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -36,7 +36,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 634fb1a4097..e4d85b7d4ee 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -33,7 +33,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index df7ed8d7c92..b0a81b01609 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -34,7 +34,7 @@ jobs: uses: ./.github/actions/setup-web - name: Setup UV and Python - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index ba61f53df7d..268766eab58 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -62,7 +62,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -100,7 +100,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -132,7 +132,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false From b6099d09ff2dc1388d747fda3aea4108b5b38730 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:33:36 -0700 Subject: [PATCH 025/531] feat(inner_api): add endpoint to invalidate plugin model providers cache (#39468) --- api/controllers/inner_api/__init__.py | 2 + .../workspace/plugin_model_providers.py | 39 +++++++++++ .../workspace/test_plugin_model_providers.py | 64 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 api/controllers/inner_api/workspace/plugin_model_providers.py create mode 100644 api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index f47861cf274..986ebd29738 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -23,6 +23,7 @@ from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_config as _agent_config from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin +from .workspace import plugin_model_providers as _plugin_model_providers from .workspace import workspace as _workspace api.add_namespace(inner_api_ns) @@ -35,6 +36,7 @@ __all__ = [ "_knowledge_retrieval", "_mail", "_plugin", + "_plugin_model_providers", "_runtime_credentials", "_workspace", "api", diff --git a/api/controllers/inner_api/workspace/plugin_model_providers.py b/api/controllers/inner_api/workspace/plugin_model_providers.py new file mode 100644 index 00000000000..50008a5bd82 --- /dev/null +++ b/api/controllers/inner_api/workspace/plugin_model_providers.py @@ -0,0 +1,39 @@ +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, Field + +from controllers.common.schema import register_schema_model +from controllers.console.wraps import setup_required +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import enterprise_inner_api_only +from core.plugin.plugin_service import PluginService + + +class InvalidatePluginModelProvidersCachePayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated") + + +register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload) + + +@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate") +class EnterprisePluginModelProvidersCacheInvalidate(Resource): + @setup_required + @enterprise_inner_api_only + @inner_api_ns.doc( + "enterprise_invalidate_plugin_model_providers_cache", + responses={ + 200: "Cache invalidated", + 400: "Invalid request", + 401: "Unauthorized - invalid API key", + }, + ) + @inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__]) + def post(self): + args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {}) + + for tenant_id in args.tenant_ids: + PluginService.invalidate_plugin_model_providers_cache(tenant_id) + + return {"result": "success"}, 200 diff --git a/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py b/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py new file mode 100644 index 00000000000..25902117ce5 --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py @@ -0,0 +1,64 @@ +import inspect +from unittest.mock import call, patch + +import pytest +from flask import Flask +from pydantic import ValidationError + +from controllers.inner_api.workspace.plugin_model_providers import ( + EnterprisePluginModelProvidersCacheInvalidate, + InvalidatePluginModelProvidersCachePayload, +) + + +class TestInvalidatePluginModelProvidersCachePayload: + def test_valid_payload(self): + payload = InvalidatePluginModelProvidersCachePayload.model_validate( + {"tenant_ids": ["tenant-alpha", "tenant-beta"]} + ) + assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"] + + def test_missing_tenant_ids_defaults_to_empty(self): + payload = InvalidatePluginModelProvidersCachePayload.model_validate({}) + assert payload.tenant_ids == [] + + def test_unknown_field_rejected(self): + with pytest.raises(ValidationError): + InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7}) + + +class TestEnterprisePluginModelProvidersCacheInvalidate: + @pytest.fixture + def api_instance(self): + return EnterprisePluginModelProvidersCacheInvalidate() + + def _post(self, api_instance, app: Flask, payload): + unwrapped_post = inspect.unwrap(api_instance.post) + with app.test_request_context(): + with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns: + mock_ns.payload = payload + return unwrapped_post(api_instance) + + @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") + def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask): + result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]}) + + assert result == ({"result": "success"}, 200) + assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [ + call("tenant-alpha"), + call("tenant-beta"), + ] + + @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") + def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask): + result = self._post(api_instance, app, {"tenant_ids": []}) + + assert result == ({"result": "success"}, 200) + mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called() + + @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") + def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask): + result = self._post(api_instance, app, None) + + assert result == ({"result": "success"}, 200) + mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called() From 28d174603f8fab29ed389e24249dca756d83f6b5 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:01:02 +0800 Subject: [PATCH 026/531] fix: align workspace card plan ownership (#39616) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/services/workspace_service.py | 2 +- .../services/test_workspace_service.py | 18 ++- .../main-nav/__tests__/index.spec.tsx | 29 ++--- .../__tests__/workspace-card.spec.tsx | 113 +++++++++++++----- .../main-nav/components/workspace-card.tsx | 74 ++++++------ .../components/workspace-switcher.tsx | 92 ++++++++------ .../__tests__/console-bootstrap.spec.tsx | 1 + 7 files changed, 204 insertions(+), 125 deletions(-) diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py index 5f9003bb755..0635f519438 100644 --- a/api/services/workspace_service.py +++ b/api/services/workspace_service.py @@ -27,7 +27,6 @@ class WorkspaceService: tenant_info: dict[str, object] = { "id": tenant.id, "name": tenant.name, - "plan": tenant.plan, "status": tenant.status, "created_at": tenant.created_at, "trial_end_reason": None, @@ -44,6 +43,7 @@ class WorkspaceService: tenant_info["role"] = tenant_account_join.role feature = FeatureService.get_features(tenant.id, exclude_vector_space=True) + tenant_info["plan"] = feature.billing.subscription.plan if feature.billing.enabled else None can_replace_logo = feature.can_replace_logo if can_replace_logo and TenantService.has_roles( diff --git a/api/tests/test_containers_integration_tests/services/test_workspace_service.py b/api/tests/test_containers_integration_tests/services/test_workspace_service.py index f22775a13eb..1294156273d 100644 --- a/api/tests/test_containers_integration_tests/services/test_workspace_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workspace_service.py @@ -24,7 +24,10 @@ class TestWorkspaceService: patch("services.workspace_service.dify_config") as mock_dify_config, ): # Setup default mock returns - mock_feature_service.get_features.return_value.can_replace_logo = True + feature = mock_feature_service.get_features.return_value + feature.can_replace_logo = True + feature.billing.enabled = True + feature.billing.subscription.plan = "professional" mock_tenant_service.has_roles.return_value = True mock_dify_config.FILES_URL = "https://example.com/files" @@ -112,7 +115,7 @@ class TestWorkspaceService: assert result is not None assert result["id"] == tenant.id assert result["name"] == tenant.name - assert result["plan"] == tenant.plan + assert result["plan"] == "professional" assert result["status"] == tenant.status assert result["role"] == TenantAccountRole.OWNER assert result["created_at"] == tenant.created_at @@ -159,7 +162,7 @@ class TestWorkspaceService: assert result is not None assert result["id"] == tenant.id assert result["name"] == tenant.name - assert result["plan"] == tenant.plan + assert result["plan"] == "professional" assert result["status"] == tenant.status assert result["role"] == TenantAccountRole.OWNER assert result["created_at"] == tenant.created_at @@ -214,7 +217,7 @@ class TestWorkspaceService: assert result is not None assert result["id"] == tenant.id assert result["name"] == tenant.name - assert result["plan"] == tenant.plan + assert result["plan"] == "professional" assert result["status"] == tenant.status assert result["role"] == TenantAccountRole.NORMAL assert result["created_at"] == tenant.created_at @@ -606,20 +609,23 @@ class TestWorkspaceService: def test_get_tenant_info_should_not_include_cloud_fields_in_self_hosted( self, db_session_with_containers: Session, mock_external_service_dependencies ): - """next_credit_reset_date and trial_credits should NOT appear in SELF_HOSTED mode.""" + """Cloud-only billing data should not appear in SELF_HOSTED mode.""" fake = Faker() account, tenant = self._create_test_account_and_tenant( db_session_with_containers, mock_external_service_dependencies ) mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY - mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False + feature = mock_external_service_dependencies["feature_service"].get_features.return_value + feature.can_replace_logo = False + feature.billing.enabled = False mock_external_service_dependencies["tenant_service"].has_roles.return_value = False with patch("services.workspace_service.current_user", account): result = WorkspaceService.get_tenant_info(tenant, db_session_with_containers) assert result is not None + assert result["plan"] is None assert "next_credit_reset_date" not in result assert "trial_credits" not in result assert "trial_credits_used" not in result diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 0dfe17e4790..4a30a3edbd0 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -401,7 +401,7 @@ const consoleState: ConsoleStateFixture = { currentWorkspace: { id: 'workspace-1', name: 'Solar Studio', - plan: Plan.sandbox, + plan: Plan.team, status: 'normal', created_at: 0, role: 'owner', @@ -1125,16 +1125,13 @@ describe('MainNav', () => { }) it('shows the upgrade shortcut for sandbox workspaces', () => { - mockWorkspaces = [ - { - id: 'workspace-1', - name: 'Solar Studio', + mockConsoleState.current = { + ...consoleState, + currentWorkspace: { + ...consoleState.currentWorkspace, plan: Plan.sandbox, - status: 'normal', - created_at: 0, - current: true, }, - ] + } renderMainNav() @@ -1143,13 +1140,13 @@ describe('MainNav', () => { }) it('shows the view plan shortcut for paid workspaces', () => { - ;(useProviderContext as Mock).mockReturnValue({ - enableBilling: true, - isEducationAccount: false, - isEducationWorkspace: false, - isFetchedPlan: true, - plan: { type: Plan.team }, - } as ProviderContextState) + mockConsoleState.current = { + ...consoleState, + currentWorkspace: { + ...consoleState.currentWorkspace, + plan: Plan.professional, + }, + } renderMainNav() diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index 2beb8ae09f0..dfc1b35d04b 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -2,6 +2,7 @@ import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import type { ICurrentWorkspace, IWorkspace } from '@/models/common' import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { Plan } from '@/app/components/billing/type' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' @@ -15,13 +16,17 @@ import { } from '@/test/console/query-data' import { WorkspaceCard } from '../workspace-card' -const { mockSwitchWorkspace, mockCurrentWorkspaceQueryKey, mockWorkspacesQueryKey } = vi.hoisted( - () => ({ - mockSwitchWorkspace: vi.fn(), - mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'post'] as const, - mockWorkspacesQueryKey: ['console', 'workspaces', 'get'] as const, - }), -) +const { + mockFetchWorkspaces, + mockSwitchWorkspace, + mockCurrentWorkspaceQueryKey, + mockWorkspacesQueryKey, +} = vi.hoisted(() => ({ + mockFetchWorkspaces: vi.fn(), + mockSwitchWorkspace: vi.fn(), + mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'post'] as const, + mockWorkspacesQueryKey: ['console', 'workspaces', 'get'] as const, +})) const mockConsoleState = vi.hoisted(() => ({ current: { workspacePermissionKeys: [] as string[], @@ -64,9 +69,10 @@ vi.mock('@/service/client', async (importOriginal) => { }, get: { queryKey: () => mockWorkspacesQueryKey, - queryOptions: () => ({ + queryOptions: (options?: object) => ({ queryKey: mockWorkspacesQueryKey, - queryFn: () => new Promise(() => {}), + queryFn: mockFetchWorkspaces, + ...options, }), }, switch: { @@ -163,6 +169,7 @@ describe('WorkspaceCard', () => { current: false, }, ] + mockFetchWorkspaces.mockResolvedValue({ workspaces: mockWorkspaces }) mockSwitchWorkspace.mockReturnValue(new Promise(() => {})) mockCurrentWorkspaceQuery() vi.mocked(useProviderContext).mockReturnValue({ @@ -210,16 +217,68 @@ describe('WorkspaceCard', () => { expect(screen.queryByText('Evan Workspace')).not.toBeInTheDocument() }) - it('renders a skeleton while the workspaces query has no data', () => { + it('renders the current workspace before loading the workspace list', async () => { + const user = userEvent.setup() renderWorkspaceCard({ seedWorkspaces: false }) expect( - screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' }), - ).not.toBeInTheDocument() - expect(screen.queryByText('Solar Studio')).not.toBeInTheDocument() + screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).toBeInTheDocument() + expect(screen.getByText('Solar Studio')).toBeInTheDocument() + expect(mockFetchWorkspaces).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + + expect(await screen.findByRole('dialog', { name: 'Solar Studio' })).toBeInTheDocument() + await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce()) + expect(await screen.findByRole('button', { name: 'Evan Workspace' })).toBeInTheDocument() }) - it('uses the workspaces query current item for billing plan UI', () => { + it('prefetches the workspace list when the trigger is hovered', async () => { + const user = userEvent.setup() + renderWorkspaceCard({ seedWorkspaces: false }) + + const trigger = screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }) + await user.hover(trigger) + + await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce()) + expect(screen.queryByRole('dialog', { name: 'Solar Studio' })).not.toBeInTheDocument() + + await user.click(trigger) + + expect(await screen.findByRole('button', { name: 'Evan Workspace' })).toBeInTheDocument() + expect(mockFetchWorkspaces).toHaveBeenCalledOnce() + }) + + it('prefetches the workspace list when the trigger receives keyboard focus', async () => { + const user = userEvent.setup() + renderWorkspaceCard({ seedWorkspaces: false }) + + await user.tab() + + expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toHaveFocus() + await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce()) + expect(screen.queryByRole('dialog', { name: 'Solar Studio' })).not.toBeInTheDocument() + }) + + it('keeps workspace controls visible and disabled while the workspace list is loading', async () => { + const user = userEvent.setup() + mockFetchWorkspaces.mockReturnValue(new Promise(() => {})) + renderWorkspaceCard({ seedWorkspaces: false }) + + await user.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + + const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) + expect(within(panel).getByText('common.userProfile.workspace')).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }), + ).toBeDisabled() + expect(within(panel).getByRole('button', { name: 'common.operation.search' })).toBeDisabled() + expect(panel.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect(within(panel).queryByRole('button', { name: 'Evan Workspace' })).not.toBeInTheDocument() + }) + + it('uses the current workspace query for billing plan UI', () => { mockCurrentWorkspaceQuery({ ...currentWorkspaceValue, plan: Plan.team, @@ -234,23 +293,17 @@ describe('WorkspaceCard', () => { renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) - expect(screen.getByText(Plan.sandbox)).toBeInTheDocument() - expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() - expect(screen.queryByText(Plan.team)).not.toBeInTheDocument() - expect(screen.queryByText('billing.upgradeBtn.plain')).not.toBeInTheDocument() + expect(screen.getByText(Plan.team)).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.plain')).toBeInTheDocument() + expect(screen.queryByText(Plan.sandbox)).not.toBeInTheDocument() + expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() }) it('uses the original paid plan badge for paid workspaces', () => { - mockWorkspaces = [ - { - id: 'workspace-1', - name: 'Solar Studio', - plan: Plan.team, - status: 'normal', - created_at: 0, - current: true, - }, - ] + mockCurrentWorkspaceQuery({ + ...currentWorkspaceValue, + plan: Plan.team, + }) vi.mocked(useProviderContext).mockReturnValue({ enableBilling: true, isEducationAccount: false, @@ -265,6 +318,10 @@ describe('WorkspaceCard', () => { }) it('shows the Enterprise license status independently of the Cloud billing state', () => { + mockCurrentWorkspaceQuery({ + ...currentWorkspaceValue, + plan: '', + }) vi.mocked(useProviderContext).mockReturnValue({ enableBilling: true, isEducationAccount: false, diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx index fe510a89de5..4c446efa3e5 100644 --- a/web/app/components/main-nav/components/workspace-card.tsx +++ b/web/app/components/main-nav/components/workspace-card.tsx @@ -1,10 +1,11 @@ 'use client' +import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -89,6 +90,7 @@ function WorkspaceCardTrigger({ showPlanAction, planActionLabel, creditsHref, + onPrefetchWorkspaces, onPlanClick, }: { open: boolean @@ -99,6 +101,7 @@ function WorkspaceCardTrigger({ showPlanAction: boolean planActionLabel: string creditsHref: string + onPrefetchWorkspaces: () => void onPlanClick: () => void }) { const { t } = useTranslation() @@ -111,6 +114,8 @@ function WorkspaceCardTrigger({ $['mainNav.workspace.openMenu'], { ns: 'common' })} title={name} + onMouseEnter={onPrefetchWorkspaces} + onFocus={onPrefetchWorkspaces} className={cn( 'flex w-full items-center gap-1.5 py-1.5 pr-3 pl-1.5 text-left transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden', showCloudBilling ? 'rounded-t-xl' : 'rounded-xl', @@ -221,21 +226,21 @@ function WorkspaceMenuHeader({ ) } -const selectCurrentWorkspaceCardData = (workspace: { - id: string - name?: string | null - role?: string | null - trial_credits?: number | null - trial_credits_used?: number | null -}) => ({ +type CurrentWorkspaceCardSource = Pick< + PostWorkspacesCurrentResponse, + 'id' | 'name' | 'plan' | 'trial_credits' | 'trial_credits_used' +> + +const selectCurrentWorkspaceCardData = (workspace: CurrentWorkspaceCardSource) => ({ id: workspace.id, name: workspace.name, - role: workspace.role, + plan: workspace.plan, credits: getRemainingCredits(workspace.trial_credits ?? 0, workspace.trial_credits_used ?? 0), }) export function WorkspaceCard() { const { t } = useTranslation() + const queryClient = useQueryClient() const { data: deploymentEdition } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: ({ deployment_edition }) => deployment_edition, @@ -245,27 +250,24 @@ export function WorkspaceCard() { select: selectCurrentWorkspaceCardData, }), ) - const workspacesQuery = useQuery(consoleQuery.workspaces.get.queryOptions()) + const [open, setOpen] = useState(false) + const workspacesQueryOptions = consoleQuery.workspaces.get.queryOptions() + const workspacesQuery = useQuery({ + ...workspacesQueryOptions, + enabled: open, + }) const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions()) const currentWorkspace = currentWorkspaceQuery.data - const workspacesData = workspacesQuery.data - const workspaces = workspacesData?.workspaces - const currentWorkspaceInList = workspaces?.find((workspace) => workspace.current) + const workspaces = workspacesQuery.data?.workspaces const { enableBilling } = useProviderContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const showCloudBilling = deploymentEdition === 'CLOUD' && enableBilling - const [open, setOpen] = useState(false) + const prefetchWorkspaces = () => { + void queryClient.prefetchQuery(workspacesQueryOptions) + } - if ( - currentWorkspaceQuery.isPending || - workspacesQuery.isPending || - !currentWorkspace?.name || - !currentWorkspace.role || - !workspaces || - !currentWorkspaceInList || - !isWorkspacePlan(currentWorkspaceInList.plan) - ) { + if (currentWorkspaceQuery.isPending || !currentWorkspace?.name) { return ( $[isFreePlan ? 'upgradeBtn.encourageShort' : 'upgradeBtn.plain'], { ns: 'billing' }, @@ -284,7 +286,7 @@ export function WorkspaceCard() { const showInviteMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage') const renderWorkspaceStatus = () => { if (deploymentEdition === 'CLOUD') - return enableBilling ? : null + return enableBilling && workspacePlan ? : null if (deploymentEdition === 'ENTERPRISE') return return null } @@ -313,6 +315,7 @@ export function WorkspaceCard() { showPlanAction={showPlanAction} planActionLabel={planActionLabel} creditsHref={buildIntegrationPath('provider')} + onPrefetchWorkspaces={prefetchWorkspaces} onPlanClick={setShowPricingModal} /> - {workspaces.length > 0 && ( -
- { - setOpen(false) - void handleSwitchWorkspace(workspaceId) - }} - /> -
- )} + { + setOpen(false) + void handleSwitchWorkspace(workspaceId) + }} + />
diff --git a/web/app/components/main-nav/components/workspace-switcher.tsx b/web/app/components/main-nav/components/workspace-switcher.tsx index 86cb90c9706..debaff3d384 100644 --- a/web/app/components/main-nav/components/workspace-switcher.tsx +++ b/web/app/components/main-nav/components/workspace-switcher.tsx @@ -17,7 +17,7 @@ import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar' import { WorkspaceMenuItemContent } from './workspace-menu-content' const workspaceSwitchActionButtonClassName = - 'flex shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid' + 'flex shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled' const workspaceSwitchActionIconWrapClassName = 'flex size-5 shrink-0 items-center justify-center' const workspaceSwitchActionIconClassName = 'size-3.5 shrink-0' const workspaceSwitchListClassName = 'max-h-[240px] overflow-y-auto overscroll-contain scroll-py-1' @@ -30,11 +30,13 @@ const getWorkspaceLastOpenedAt = (workspace: TenantListItemResponse) => workspace.last_opened_at ?? 0 function WorkspaceSwitchControls({ + disabled, searchText, sort, onSearchTextChange, onSortChange, }: { + disabled: boolean searchText: string sort: WorkspaceSort onSearchTextChange: (value: string) => void @@ -72,6 +74,7 @@ function WorkspaceSwitchControls({ $['operation.search'], { ns: 'common' })} + disabled={disabled} className={cn( workspaceSwitchActionButtonClassName, searchVisible && 'bg-state-base-hover text-text-secondary', @@ -142,20 +146,25 @@ function WorkspaceSwitchControls({ } type WorkspaceSwitcherProps = { - workspaces: TenantListItemResponse[] + workspaces?: TenantListItemResponse[] + isPending: boolean onSwitchWorkspace: (workspaceId: string) => void } -export function WorkspaceSwitcher({ workspaces, onSwitchWorkspace }: WorkspaceSwitcherProps) { +export function WorkspaceSwitcher({ + workspaces, + isPending, + onSwitchWorkspace, +}: WorkspaceSwitcherProps) { const [workspaceSearchText, setWorkspaceSearchText] = useState('') const [workspaceSort, setWorkspaceSort] = useState('lastOpened') const displayedWorkspaces = useMemo(() => { const normalizedSearchText = workspaceSearchText.trim().toLowerCase() const filteredWorkspaces = normalizedSearchText - ? workspaces.filter((workspace) => + ? (workspaces?.filter((workspace) => getWorkspaceName(workspace).toLowerCase().includes(normalizedSearchText), - ) - : [...workspaces] + ) ?? []) + : [...(workspaces ?? [])] if (workspaceSort === 'createdAt') return filteredWorkspaces.sort((a, b) => getWorkspaceCreatedAt(b) - getWorkspaceCreatedAt(a)) @@ -168,45 +177,54 @@ export function WorkspaceSwitcher({ workspaces, onSwitchWorkspace }: WorkspaceSw }) }, [workspaceSearchText, workspaceSort, workspaces]) + if (!isPending && !workspaces) return null + return ( - <> +
-
- {displayedWorkspaces.map((workspace) => { - const workspaceName = getWorkspaceName(workspace) +
+ {isPending ? ( +
+ +
+ ) : ( + displayedWorkspaces.map((workspace) => { + const workspaceName = getWorkspaceName(workspace) - return ( - - ) - })} + return ( + + ) + }) + )}
- +
) } diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx index 53936b20974..61987812eb7 100644 --- a/web/context/__tests__/console-bootstrap.spec.tsx +++ b/web/context/__tests__/console-bootstrap.spec.tsx @@ -556,6 +556,7 @@ describe('Console bootstrap', () => { expect.objectContaining({ email: 'user@example.com', workspace_id: 'workspace-1', + workspace_plan: 'sandbox', workspace_role: 'editor', }), ) From 52428df1bd2fb09d4d61577923dbd7f2401518c8 Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 27 Jul 2026 13:54:45 +0800 Subject: [PATCH 027/531] feat: add amptitude to new agent (#39608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 林玮 (Jade Lin) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/chat-wrapper.spec.tsx | 39 ++++++++++++++++++- .../chat/chat-with-history/chat-wrapper.tsx | 5 +++ .../__tests__/chat-wrapper.spec.tsx | 11 ++++++ .../chat/embedded-chatbot/chat-wrapper.tsx | 4 ++ .../hooks/__tests__/use-result-sender.spec.ts | 27 +++++++++++++ .../result/hooks/use-result-sender.ts | 7 ++++ .../configure/__tests__/page.spec.tsx | 11 +++++- .../use-agent-configure-sync.spec.tsx | 16 ++++++++ .../configure/components/composer-session.tsx | 6 ++- .../configure/use-agent-configure-sync.ts | 10 +++++ .../__tests__/create-agent-dialog.spec.tsx | 10 +++++ .../roster/components/create-agent-dialog.tsx | 5 +++ web/models/share.ts | 2 + .../__tests__/create-app-tracking.spec.ts | 17 ++++++++ web/utils/create-app-tracking.ts | 4 +- 15 files changed, 170 insertions(+), 4 deletions(-) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index 01390834a7f..680915c7138 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -17,6 +17,12 @@ import { isValidGeneratedAnswer } from '../../utils' import ChatWrapper from '../chat-wrapper' import { useChatWithHistoryContext } from '../context' +const mockTrackEvent = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mockTrackEvent, +})) + vi.mock('../../chat/hooks', () => ({ useChat: vi.fn(), })) @@ -75,6 +81,7 @@ vi.mock('@/hooks/use-timestamp', () => ({ type ChatHookReturn = ReturnType const mockAppData = { + mode: 'advanced-chat', site: { title: 'Test Chat', chat_color_theme: 'blue', @@ -745,7 +752,34 @@ describe('ChatWrapper', () => { expect(fetchChatList).toHaveBeenCalledWith('conversation-1', 'webApp', 'test-app-id') }) - it('should not fetch current conversation messages for non-new-agent chat', async () => { + it('should track the start action when a new agent web app sends a message', async () => { + const handleSend = vi.fn() + vi.mocked(useChat).mockReturnValue({ + ...defaultChatHookReturn, + handleSend, + chatList: [ + { id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }, + ], + suggestedQuestions: ['Q1'], + } as unknown as ChatHookReturn) + vi.mocked(useChatWithHistoryContext).mockReturnValue({ + ...defaultContextValue, + currentConversationId: '', + isInstalledApp: false, + isNewAgent: true, + }) + + render() + + fireEvent.click(await screen.findByText('Q1')) + + expect(handleSend).toHaveBeenCalled() + expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', { + app_mode: 'agent-v2', + }) + }) + + it('should track the site response mode without fetching messages for a regular web app', async () => { const handleSend = vi.fn() vi.mocked(useChat).mockReturnValue({ ...defaultChatHookReturn, @@ -768,6 +802,9 @@ describe('ChatWrapper', () => { const options = handleSend.mock.calls[0]![2] expect(options.onGetConversationMessages).toBeUndefined() + expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', { + app_mode: 'advanced-chat', + }) }) it('should call fetchSuggestedQuestions in doSwitchSibling', async () => { diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 6fee75fdcd0..39a9dc4388e 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -6,6 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { trackEvent } from '@/app/components/base/amplitude' import AnswerIcon from '@/app/components/base/answer-icon' import AppIcon from '@/app/components/base/app-icon' import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form' @@ -221,6 +222,9 @@ const ChatWrapper = () => { onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted, isPublicAPI: appSourceType === AppSourceType.webApp, }) + const appMode = isNewAgent ? 'agent-v2' : appData?.mode + if (appSourceType === AppSourceType.webApp && appMode) + trackEvent('webapp_run', { app_mode: appMode }) }, [ inputsForms, @@ -234,6 +238,7 @@ const ChatWrapper = () => { isHistoryConversation, handleNewConversationCompleted, isNewAgent, + appData?.mode, ], ) diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx index 0416d8c0533..f06a28de716 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/chat-wrapper.spec.tsx @@ -11,6 +11,12 @@ import { useChat } from '../../chat/hooks' import ChatWrapper from '../chat-wrapper' import { useEmbeddedChatbotContext } from '../context' +const mockTrackEvent = vi.hoisted(() => vi.fn()) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mockTrackEvent, +})) + vi.mock('../context', () => ({ useEmbeddedChatbotContext: vi.fn(), })) @@ -132,6 +138,7 @@ const createContextValue = ( appMeta: { tool_icons: {} }, appData: { app_id: 'app-1', + mode: 'chat', can_replace_logo: true, custom_config: { remove_webapp_brand: false, @@ -533,6 +540,7 @@ describe('EmbeddedChatbot chat-wrapper', () => { expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-2', AppSourceType.tryApp, 'app-1') expect(handleStop).toHaveBeenCalled() expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument() + expect(mockTrackEvent).not.toHaveBeenCalled() cleanup() vi.mocked(useEmbeddedChatbotContext).mockReturnValue( @@ -739,6 +747,9 @@ describe('EmbeddedChatbot chat-wrapper', () => { fireEvent.click(screen.getByRole('button', { name: 'send through chat' })) expect(handleSend).toHaveBeenCalled() + expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', { + app_mode: 'chat', + }) const options = handleSend.mock.calls[0]?.[2] as { onConversationComplete?: (id: string) => void } diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx index e4c089c0d1a..839b2fe3a40 100644 --- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx @@ -6,6 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { trackEvent } from '@/app/components/base/amplitude' import AnswerIcon from '@/app/components/base/answer-icon' import AppIcon from '@/app/components/base/app-icon' import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions' @@ -207,6 +208,8 @@ const ChatWrapper = () => { onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, isPublicAPI: appSourceType === AppSourceType.webApp, }) + if (appSourceType === AppSourceType.webApp && appData?.mode) + trackEvent('webapp_run', { app_mode: appData.mode }) }, [ currentConversationId, @@ -217,6 +220,7 @@ const ChatWrapper = () => { appSourceType, appId, handleNewConversationCompleted, + appData?.mode, ], ) diff --git a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts index 9de624ca143..8527eaa7f8b 100644 --- a/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts +++ b/web/app/components/share/text-generation/result/hooks/__tests__/use-result-sender.spec.ts @@ -12,6 +12,8 @@ import { useResultSender } from '../use-result-sender' const { buildResultRequestDataMock, createWorkflowStreamHandlersMock, + mockTrackEvent, + mockWebAppState, sendCompletionMessageMock, sendWorkflowMessageMock, sleepMock, @@ -19,12 +21,27 @@ const { } = vi.hoisted(() => ({ buildResultRequestDataMock: vi.fn(), createWorkflowStreamHandlersMock: vi.fn(), + mockTrackEvent: vi.fn(), + mockWebAppState: { + appInfo: { + mode: 'completion', + }, + }, sendCompletionMessageMock: vi.fn(), sendWorkflowMessageMock: vi.fn(), sleepMock: vi.fn(), validateResultRequestMock: vi.fn(), })) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mockTrackEvent, +})) + +vi.mock('@/context/web-app-context', () => ({ + useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => + selector(mockWebAppState), +})) + vi.mock('@/service/share', async () => { const actual = await vi.importActual('@/service/share') return { @@ -226,6 +243,7 @@ const renderSender = ({ describe('useResultSender', () => { beforeEach(() => { vi.clearAllMocks() + mockWebAppState.appInfo.mode = 'completion' validateResultRequestMock.mockReturnValue({ canSend: true }) buildResultRequestDataMock.mockReturnValue({ inputs: { name: 'Alice' } }) createWorkflowStreamHandlersMock.mockReturnValue({ onWorkflowFinished: vi.fn() }) @@ -273,6 +291,7 @@ describe('useResultSender', () => { }) expect(buildResultRequestDataMock).not.toHaveBeenCalled() expect(sendCompletionMessageMock).not.toHaveBeenCalled() + expect(mockTrackEvent).not.toHaveBeenCalled() }) it('should send completion requests when controlSend changes and process callbacks', async () => { @@ -307,6 +326,9 @@ describe('useResultSender', () => { expect(harness.runState.clearMoreLikeThis).toHaveBeenCalledTimes(1) expect(onShowRes).toHaveBeenCalledTimes(1) expect(onRunStart).toHaveBeenCalledTimes(1) + expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', { + app_mode: 'completion', + }) expect(sendCompletionMessageMock).toHaveBeenCalledWith( { inputs: { name: 'Alice' } }, expect.objectContaining({ @@ -346,6 +368,7 @@ describe('useResultSender', () => { it('should trigger workflow sends on retry and report workflow request failures', async () => { const harness = createRunStateHarness() + mockWebAppState.appInfo.mode = 'workflow' sendWorkflowMessageMock.mockRejectedValue(new Error('workflow failed')) const { rerender, notify } = renderSender({ @@ -385,6 +408,9 @@ describe('useResultSender', () => { }) }) expect(harness.runState.clearMoreLikeThis).not.toHaveBeenCalled() + expect(mockTrackEvent).toHaveBeenCalledWith('webapp_run', { + app_mode: 'workflow', + }) }) it('should configure workflow handlers for installed apps as non-public', async () => { @@ -411,6 +437,7 @@ describe('useResultSender', () => { AppSourceTypeEnum.installedApp, 'app-1', ) + expect(mockTrackEvent).not.toHaveBeenCalled() }) it('should stringify non-Error workflow failures', async () => { diff --git a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts index ba3effe5da6..720e5fe4fe3 100644 --- a/web/app/components/share/text-generation/result/hooks/use-result-sender.ts +++ b/web/app/components/share/text-generation/result/hooks/use-result-sender.ts @@ -4,7 +4,9 @@ import type { ResultRunStateController } from './use-result-run-state' import type { PromptConfig } from '@/models/debug' import type { VisionFile, VisionSettings } from '@/types/app' import { useCallback, useEffect, useRef } from 'react' +import { trackEvent } from '@/app/components/base/amplitude' import { TEXT_GENERATION_TIMEOUT_MS } from '@/config' +import { useWebAppStore } from '@/context/web-app-context' import { AppSourceType, sendCompletionMessage, sendWorkflowMessage } from '@/service/share' import { sleep } from '@/utils' import { buildResultRequestData, validateResultRequest } from '../result-request' @@ -58,6 +60,7 @@ export const useResultSender = ({ visionConfig, }: UseResultSenderOptions) => { const { clearMoreLikeThis } = runState + const appMode = useWebAppStore((state) => state.appInfo?.mode) const handleSend = useCallback(async () => { if (runState.isResponding) { @@ -96,6 +99,9 @@ export const useResultSender = ({ runState.setRespondingTrue() + if (appSourceType === AppSourceType.webApp && appMode) + trackEvent('webapp_run', { app_mode: appMode }) + let isEnd = false let isTimeout = false let completionChunks: string[] = [] @@ -196,6 +202,7 @@ export const useResultSender = ({ return true }, [ appId, + appMode, appSourceType, completionFiles, inputs, diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index 1f64a7613f1..c147cb2543c 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -68,6 +68,8 @@ const toastMock = vi.hoisted(() => ({ success: vi.fn(), })) +const trackEventMock = vi.hoisted(() => vi.fn()) + const modelHooksState = vi.hoisted(() => ({ defaultTextGenerationModel: { provider: { @@ -194,6 +196,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock, })) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: trackEventMock, +})) + vi.mock('@/service/client', () => ({ consoleQuery: { systemFeatures: { @@ -1252,6 +1258,7 @@ describe('AgentConfigurePage', () => { 'prompt:edited draft prompt', ) expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + expect(trackEventMock).not.toHaveBeenCalled() }) it('should stay in Preview when resetting the Build conversation fails', async () => { @@ -1374,6 +1381,7 @@ describe('AgentConfigurePage', () => { expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent( 'draftType:draft', ) + expect(trackEventMock).not.toHaveBeenCalled() expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( 'readonly:no', ) @@ -1904,7 +1912,7 @@ describe('AgentConfigurePage', () => { expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument() }) - it('should not checkout again when sending build chat from active build draft mode', async () => { + it('should track the run without checking out again in active build draft mode', async () => { const queryClient = new QueryClient() mocks.queryState.composer = { data: { @@ -1955,6 +1963,7 @@ describe('AgentConfigurePage', () => { expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') }) expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() + expect(trackEventMock).toHaveBeenCalledWith('agent_build_mode_run') }) it('should show the working directory action after the first build reply completes', async () => { diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index e8e340327c8..c5d1a13da47 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -18,6 +18,8 @@ const toastMock = vi.hoisted(() => ({ success: vi.fn(), })) +const trackEventMock = vi.hoisted(() => vi.fn()) + const composerPutMutationFn = vi.hoisted(() => vi.fn( async (variables: { @@ -119,6 +121,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMock, })) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: trackEventMock, +})) + vi.mock('@/service/client', () => ({ consoleQuery: { agent: { @@ -159,9 +165,11 @@ vi.mock('@/service/client', () => ({ })) function renderUseAgentConfigureSync({ + agentName = 'Agent', baseConfig, currentModel, }: { + agentName?: Parameters[0]['agentName'] baseConfig?: Parameters[0]['baseConfig'] currentModel?: Parameters[0]['currentModel'] } = {}) { @@ -183,6 +191,7 @@ function renderUseAgentConfigureSync({ () => useAgentConfigureSync({ agentId: 'agent-1', + agentName, baseConfig, currentModel, enabled: true, @@ -762,6 +771,12 @@ describe('useAgentConfigureSync', () => { active_config_is_published: true, name: 'Agent', }) + expect(trackEventMock).toHaveBeenCalledWith('app_published_time', { + action_mode: 'app', + app_id: 'agent-1', + app_name: 'Agent', + app_mode: 'agent-v2', + }) expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess') }) @@ -781,6 +796,7 @@ describe('useAgentConfigureSync', () => { expect(composerPutMutationFn).not.toHaveBeenCalled() expect(publishAgentMutationFn).not.toHaveBeenCalled() + expect(trackEventMock).not.toHaveBeenCalled() expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel') }) diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx index c0db1aa220c..a845da96da4 100644 --- a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx @@ -14,6 +14,7 @@ import { useAtomValue, useSetAtom } from 'jotai' import { ScopeProvider } from 'jotai-scope' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { trackEvent } from '@/app/components/base/amplitude' import Loading from '@/app/components/base/loading' import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' @@ -328,6 +329,7 @@ function AgentConfigurePageComposerContent({ useAgentConfigureModelOptions() const { draftSavedAt, isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({ agentId, + agentName: agentQuery.data?.name, baseConfig: agentSoulConfig, currentModel, enabled: composerQuery.isSuccess && !selectedVersionId && !buildDraft.isActive, @@ -564,11 +566,13 @@ function AgentConfigurePageComposerContent({ throw new Error('Agent model is required.') } - return runBuildPreparation({ + const preparedBuildDraft = await runBuildPreparation({ generation: buildCallbackGeneration, markBuildChatStarted: true, prepare: buildDraftActions.prepareBuildDraftBeforeRun, }) + trackEvent('agent_build_mode_run') + return preparedBuildDraft } : saveDraft } diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts index b2c5b06c427..4496ff774c3 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts @@ -10,6 +10,7 @@ import isEqual from 'fast-deep-equal' import { useSetAtom, useStore } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { trackEvent } from '@/app/components/base/amplitude' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' import { @@ -29,11 +30,13 @@ const DRAFT_AUTOSAVE_WAIT = 5000 export function useAgentConfigureSync({ agentId, + agentName, baseConfig, currentModel, enabled, }: { agentId: string + agentName?: string | null baseConfig?: AgentSoulConfig currentModel?: DefaultModel enabled: boolean @@ -303,6 +306,12 @@ export function useAgentConfigureSync({ const publishedDraft = draft setOriginalDraft(publishedDraft) setPublishedDraft(publishedDraft) + trackEvent('app_published_time', { + action_mode: 'app', + app_id: agentId, + app_name: agentName, + app_mode: 'agent-v2', + }) toast.success(tCommon(($) => $['api.actionSuccess'])) } finally { publishInFlightRef.current = false @@ -310,6 +319,7 @@ export function useAgentConfigureSync({ } }, [ agentId, + agentName, debouncedSaveDraft, getKnowledgeValidationMessage, publishAgent, diff --git a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx index bb07a3ac588..d5ca96f19a7 100644 --- a/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/create-agent-dialog.spec.tsx @@ -14,6 +14,8 @@ const toastMock = vi.hoisted(() => ({ const routerPushMock = vi.hoisted(() => vi.fn()) +const trackCreateAppMock = vi.hoisted(() => vi.fn()) + vi.mock('@tanstack/react-query', () => ({ useMutation: () => ({ isPending: mutationMock.isPending, @@ -31,6 +33,10 @@ vi.mock('@/next/navigation', () => ({ }), })) +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: trackCreateAppMock, +})) + vi.mock('@/service/client', () => ({ consoleQuery: { agent: { @@ -106,6 +112,10 @@ describe('CreateAgentDialog', () => { }) expect(toastMock.success).toHaveBeenCalledWith('agentV2.roster.createSuccess') + expect(trackCreateAppMock).toHaveBeenCalledWith({ + source: 'studio_blank', + appMode: 'agent-v2', + }) expect(routerPushMock).toHaveBeenCalledWith('/agents/agent-1/configure') }) diff --git a/web/features/agent-v2/roster/components/create-agent-dialog.tsx b/web/features/agent-v2/roster/components/create-agent-dialog.tsx index b7a593f684d..274fc4d0908 100644 --- a/web/features/agent-v2/roster/components/create-agent-dialog.tsx +++ b/web/features/agent-v2/roster/components/create-agent-dialog.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next' import AppIconPicker from '@/app/components/base/app-icon-picker' import { useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' +import { trackCreateApp } from '@/utils/create-app-tracking' import { getAgentDetailPath } from '../../agent-detail/routes' import { defaultAgentIcon } from './agent-form' import { AgentFormFields } from './agent-form-fields' @@ -76,6 +77,10 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps }, { onSuccess: (createdAgent) => { + trackCreateApp({ + source: 'studio_blank', + appMode: 'agent-v2', + }) toast.success(t(($) => $['roster.createSuccess'])) handleOpenChange(false) router.push(getAgentDetailPath(createdAgent.id, 'configure')) diff --git a/web/models/share.ts b/web/models/share.ts index 16d5a17f1c4..28463103d04 100644 --- a/web/models/share.ts +++ b/web/models/share.ts @@ -1,3 +1,4 @@ +import type { AppMode } from '@dify/contracts/api/web/types.gen' import type { Locale } from '@/i18n-config' import type { AppIconType } from '@/types/app' @@ -36,6 +37,7 @@ export type AppMeta = { export type CustomConfigValueType = string | number | boolean | null | undefined export type AppData = { app_id: string + mode?: AppMode can_replace_logo?: boolean custom_config: Record | null enable_site?: boolean diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts index 02bb6367469..0790ceee71e 100644 --- a/web/utils/__tests__/create-app-tracking.spec.ts +++ b/web/utils/__tests__/create-app-tracking.spec.ts @@ -141,6 +141,23 @@ describe('create-app-tracking', () => { }) }) + it('should preserve agent v2 mode as its own app mode', () => { + expect( + buildCreateAppEventPayload( + { + source: 'studio_blank', + appMode: 'agent-v2', + }, + null, + new Date(2026, 3, 13, 9, 8, 9), + ), + ).toEqual({ + source: 'studio_blank', + app_mode: 'agent-v2', + time: '04-13-09:08:09', + }) + }) + it('should fold legacy non-agent modes into chatflow', () => { expect( buildCreateAppEventPayload( diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts index 030e6e7c6cb..37e4cedc580 100644 --- a/web/utils/create-app-tracking.ts +++ b/web/utils/create-app-tracking.ts @@ -18,7 +18,7 @@ type SearchParamReader = { get: (name: string) => string | null } -type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent' +type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent' | 'agent-v2' type CreateAppSource = | 'external' @@ -78,6 +78,8 @@ const formatCreateAppTime = (date: Date) => { const mapOriginalCreateAppMode = (appMode: string): OriginalCreateAppMode => { if (appMode === AppModeEnum.WORKFLOW) return 'workflow' + if (appMode === 'agent-v2') return 'agent-v2' + if (appMode === AppModeEnum.AGENT_CHAT || appMode === 'agent') return 'agent' return 'chatflow' From 989039db6eb35b56a51610502a182b4b50d2e96b Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Mon, 27 Jul 2026 14:03:01 +0800 Subject: [PATCH 028/531] fix: use jinja sandbox (#39609) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../jinja2/jinja2_transformer.py | 12 ++-- .../workflow/nodes/test_template_transform.py | 5 +- .../jinja2/test_jinja2_sandbox.py | 67 +++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 api/tests/unit_tests/core/helper/code_executor/jinja2/test_jinja2_sandbox.py diff --git a/api/core/helper/code_executor/jinja2/jinja2_transformer.py b/api/core/helper/code_executor/jinja2/jinja2_transformer.py index d1c75c981b6..1277c9b331f 100644 --- a/api/core/helper/code_executor/jinja2/jinja2_transformer.py +++ b/api/core/helper/code_executor/jinja2/jinja2_transformer.py @@ -39,15 +39,16 @@ class Jinja2TemplateTransformer(TemplateTransformer): @override def get_runner_script(cls) -> str: runner_script = dedent(f""" - import jinja2 import json from base64 import b64decode + from jinja2.sandbox import SandboxedEnvironment # declare main function def main(**inputs): # Decode base64-encoded template to handle special characters safely template_code = b64decode('{cls._template_b64_placeholder}').decode('utf-8') - template = jinja2.Template(template_code) + env = SandboxedEnvironment() + template = env.from_string(template_code) return template.render(**inputs) # decode and prepare input dict @@ -67,12 +68,13 @@ class Jinja2TemplateTransformer(TemplateTransformer): @override def get_preload_script(cls) -> str: preload_script = dedent(""" - import jinja2 + from jinja2.sandbox import SandboxedEnvironment from base64 import b64decode def _jinja2_preload_(): - # prepare jinja2 environment, load template and render before to avoid sandbox issue - template = jinja2.Template('{{s}}') + # prepare jinja2 sandboxed environment, load template and render + env = SandboxedEnvironment() + template = env.from_string('{{s}}') template.render(s='a') if __name__ == '__main__': diff --git a/api/tests/integration_tests/workflow/nodes/test_template_transform.py b/api/tests/integration_tests/workflow/nodes/test_template_transform.py index 80489e68097..9a7b02597d0 100644 --- a/api/tests/integration_tests/workflow/nodes/test_template_transform.py +++ b/api/tests/integration_tests/workflow/nodes/test_template_transform.py @@ -17,10 +17,11 @@ class _SimpleJinja2Renderer: """Minimal Jinja2-based renderer for integration tests (no code executor).""" def render_template(self, template: str, variables: dict[str, object]) -> str: - from jinja2 import Template + from jinja2.sandbox import SandboxedEnvironment try: - return Template(template).render(**variables) + env = SandboxedEnvironment() + return env.from_string(template).render(**variables) except Exception as exc: raise TemplateRenderError(str(exc)) from exc diff --git a/api/tests/unit_tests/core/helper/code_executor/jinja2/test_jinja2_sandbox.py b/api/tests/unit_tests/core/helper/code_executor/jinja2/test_jinja2_sandbox.py new file mode 100644 index 00000000000..7ff7aa166c7 --- /dev/null +++ b/api/tests/unit_tests/core/helper/code_executor/jinja2/test_jinja2_sandbox.py @@ -0,0 +1,67 @@ +"""Verify that Jinja2 transform_caller scripts block unsafe template attacks via SandboxedEnvironment.""" + +import io +import sys + +import pytest + +from core.helper.code_executor.jinja2.jinja2_transformer import Jinja2TemplateTransformer + +MALICIOUS_TEMPLATES = [ + pytest.param( + "{{ lipsum.__globals__.__builtins__.__import__('os').popen('id').read() }}", + id="lipsum_globals_builtins", + ), + pytest.param( + "{{ ''.__class__.__mro__[1].__subclasses__() }}", + id="string_class_mro", + ), + pytest.param( + "{{ cycler.__init__.__globals__.os.popen('whoami').read() }}", + id="cycler_init_globals", + ), + pytest.param( + "{{ namespace.__init__.__globals__['__builtins__']['__import__']('os').system('id') }}", + id="namespace_init_globals", + ), +] + + +def _exec_scripts(runner: str, preload: str) -> str: + """Execute preload then runner in a shared namespace, return captured stdout.""" + ns: dict = {} + exec(compile(preload, "", "exec"), ns) # noqa: S102 + captured = io.StringIO() + old_stdout = sys.stdout + sys.stdout = captured + try: + exec(compile(runner, "", "exec"), ns) # noqa: S102 + finally: + sys.stdout = old_stdout + return captured.getvalue() + + +class TestJinja2TransformCallerSandbox: + """Test transform_caller output (runner + preload) blocks attacks and allows safe templates.""" + + @pytest.mark.parametrize("malicious_template", MALICIOUS_TEMPLATES) + def test_blocks_unsafe_template(self, malicious_template: str) -> None: + runner, preload = Jinja2TemplateTransformer.transform_caller(malicious_template, {}) + ns: dict = {} + exec(compile(preload, "", "exec"), ns) # noqa: S102 + with pytest.raises(Exception) as exc_info: + exec(compile(runner, "", "exec"), ns) # noqa: S102 + assert "unsafe" in str(exc_info.value).lower() or "security" in str(exc_info.value).lower() + + def test_renders_safe_template(self) -> None: + runner, preload = Jinja2TemplateTransformer.transform_caller( + "Hello {{ name }}, you are {{ age }} years old!", + {"name": "Alice", "age": 30}, + ) + output = _exec_scripts(runner, preload) + assert "Hello Alice, you are 30 years old!" in output + + def test_scripts_use_sandboxed_environment(self) -> None: + runner, preload = Jinja2TemplateTransformer.transform_caller("{{ x }}", {"x": 1}) + assert "SandboxedEnvironment" in runner + assert "SandboxedEnvironment" in preload From e1b55f54e6276c901852cf97cde6969a4b3bda2b Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Mon, 27 Jul 2026 14:21:20 +0800 Subject: [PATCH 029/531] feat(agent): add a squid proxy for agent sandbox (#39544) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- docker/docker-compose-template.yaml | 55 +++++++++- docker/docker-compose.middleware.yaml | 1 + docker/docker-compose.yaml | 55 +++++++++- docker/ssrf_proxy/docker-agent-entrypoint.sh | 25 +++++ docker/ssrf_proxy/docker-entrypoint.sh | 26 +++-- docker/ssrf_proxy/squid-agent.conf.template | 21 ++++ docker/ssrf_proxy/squid-common.conf.template | 90 ++++++++++++++++ docker/ssrf_proxy/squid.conf.template | 108 +------------------ docker/ssrf_proxy/test_ssrf_proxy_config.sh | 83 ++++++++++++++ 9 files changed, 346 insertions(+), 118 deletions(-) create mode 100644 docker/ssrf_proxy/docker-agent-entrypoint.sh create mode 100644 docker/ssrf_proxy/squid-agent.conf.template create mode 100644 docker/ssrf_proxy/squid-common.conf.template diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 37d7185e4a6..7049e237ba4 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -531,6 +531,14 @@ services: - ssrf_proxy_network # Local sandbox for Dify Agent shell workspaces. + # Network isolation: local_sandbox has NO direct route to `api`. Its only + # networks are `agent_sandbox_network` (so agent_backend can reach it on 5004 + # for shellctl, and it can reach agent_backend directly) and + # `local_sandbox_proxy_network` (so its egress is forced through + # agent_ssrf_proxy). + # All non-agent_backend/localhost traffic goes through the Squid forward proxy + # on port 3128, which only allows agent_backend /agent-stub/ and the Dify API + # /files/* endpoints (see ssrf_proxy/squid-agent.conf.template). local_sandbox: image: langgenius/dify-agent-local-sandbox:1.16.0 restart: always @@ -539,6 +547,9 @@ services: required: false environment: - SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + - HTTP_PROXY=http://agent_ssrf_proxy:3128 + - HTTPS_PROXY=http://agent_ssrf_proxy:3128 + - NO_PROXY=localhost,127.0.0.1 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] interval: 30s @@ -546,7 +557,8 @@ services: retries: 3 start_period: 10s networks: - - default + - agent_sandbox_network + - local_sandbox_proxy_network # plugin daemon plugin_daemon: @@ -672,6 +684,33 @@ services: condition: service_started networks: - default + # Shared internal network with local_sandbox so agent_backend can reach it + # on port 5004 (shellctl entrypoint) while local_sandbox stays off `default`. + - agent_sandbox_network + + # Dedicated SSRF proxy for the dify-agent local_sandbox. + agent_ssrf_proxy: + image: ubuntu/squid:latest + restart: always + volumes: + - ./ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template + - ./ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh + entrypoint: + [ + "sh", + "-c", + "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh", + ] + environment: + HTTP_PORT: ${SSRF_HTTP_PORT:-3128} + COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} + networks: + # Needs to reach api and agent_backend as forward-proxy destinations. + - default + # Only agent_ssrf_proxy and local_sandbox share this internal network, so + # the local_sandbox can reach Squid without gaining a direct route to `api`. + - local_sandbox_proxy_network # ssrf_proxy server # for more information, please refer to @@ -681,6 +720,7 @@ services: restart: always volumes: - ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template - ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh entrypoint: [ @@ -1253,6 +1293,19 @@ networks: ssrf_proxy_network: driver: bridge internal: true + # Internal network shared only by agent_ssrf_proxy and local_sandbox. + local_sandbox_proxy_network: + driver: bridge + internal: true + # shellctl control channel (agent_backend -> local_sandbox:5004). + # sandbox can access agent backend through this network, this is + # a known limitation. + # + # The agent runtime respects HTTP(S)_PROXY, but arbitrary code execution + # is still possible through the shellctl channel. + agent_sandbox_network: + driver: bridge + internal: true milvus: driver: bridge opensearch-net: diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml index 01f8fbde3e3..a0334afb5d7 100644 --- a/docker/docker-compose.middleware.yaml +++ b/docker/docker-compose.middleware.yaml @@ -199,6 +199,7 @@ services: restart: always volumes: - ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template - ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh entrypoint: [ diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 908e979c8cf..5eae7d4d3ca 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -537,6 +537,14 @@ services: - ssrf_proxy_network # Local sandbox for Dify Agent shell workspaces. + # Network isolation: local_sandbox has NO direct route to `api`. Its only + # networks are `agent_sandbox_network` (so agent_backend can reach it on 5004 + # for shellctl, and it can reach agent_backend directly) and + # `local_sandbox_proxy_network` (so its egress is forced through + # agent_ssrf_proxy). + # All non-agent_backend/localhost traffic goes through the Squid forward proxy + # on port 3128, which only allows agent_backend /agent-stub/ and the Dify API + # /files/* endpoints (see ssrf_proxy/squid-agent.conf.template). local_sandbox: image: langgenius/dify-agent-local-sandbox:1.16.0 restart: always @@ -545,6 +553,9 @@ services: required: false environment: - SHELLCTL_AUTH_TOKEN=${DIFY_AGENT_SHELLCTL_AUTH_TOKEN:-} + - HTTP_PROXY=http://agent_ssrf_proxy:3128 + - HTTPS_PROXY=http://agent_ssrf_proxy:3128 + - NO_PROXY=localhost,127.0.0.1 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5004/healthz"] interval: 30s @@ -552,7 +563,8 @@ services: retries: 3 start_period: 10s networks: - - default + - agent_sandbox_network + - local_sandbox_proxy_network # plugin daemon plugin_daemon: @@ -678,6 +690,33 @@ services: condition: service_started networks: - default + # Shared internal network with local_sandbox so agent_backend can reach it + # on port 5004 (shellctl entrypoint) while local_sandbox stays off `default`. + - agent_sandbox_network + + # Dedicated SSRF proxy for the dify-agent local_sandbox. + agent_ssrf_proxy: + image: ubuntu/squid:latest + restart: always + volumes: + - ./ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template + - ./ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh + entrypoint: + [ + "sh", + "-c", + "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh", + ] + environment: + HTTP_PORT: ${SSRF_HTTP_PORT:-3128} + COREDUMP_DIR: ${SSRF_COREDUMP_DIR:-/var/spool/squid} + networks: + # Needs to reach api and agent_backend as forward-proxy destinations. + - default + # Only agent_ssrf_proxy and local_sandbox share this internal network, so + # the local_sandbox can reach Squid without gaining a direct route to `api`. + - local_sandbox_proxy_network # ssrf_proxy server # for more information, please refer to @@ -687,6 +726,7 @@ services: restart: always volumes: - ./ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template + - ./ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template - ./ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh entrypoint: [ @@ -1259,6 +1299,19 @@ networks: ssrf_proxy_network: driver: bridge internal: true + # Internal network shared only by agent_ssrf_proxy and local_sandbox. + local_sandbox_proxy_network: + driver: bridge + internal: true + # shellctl control channel (agent_backend -> local_sandbox:5004). + # sandbox can access agent backend through this network, this is + # a known limitation. + # + # The agent runtime respects HTTP(S)_PROXY, but arbitrary code execution + # is still possible through the shellctl channel. + agent_sandbox_network: + driver: bridge + internal: true milvus: driver: bridge opensearch-net: diff --git a/docker/ssrf_proxy/docker-agent-entrypoint.sh b/docker/ssrf_proxy/docker-agent-entrypoint.sh new file mode 100644 index 00000000000..29dee3c48e3 --- /dev/null +++ b/docker/ssrf_proxy/docker-agent-entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +tail -F /var/log/squid/access.log 2>/dev/null & +tail -F /var/log/squid/error.log 2>/dev/null & +tail -F /var/log/squid/store.log 2>/dev/null & +tail -F /var/log/squid/cache.log 2>/dev/null & + +expand_env() { + awk '{ + while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { + var = substr($0, RSTART+2, RLENGTH-3) + val = ENVIRON[var] + $0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH) + } + print + }' "$1" +} + +echo "[ENTRYPOINT] replacing environment variables in the templates" +expand_env /etc/squid/squid.conf.template > /etc/squid/squid.conf +expand_env /etc/squid/dify_common.conf.template > /etc/squid/dify_common.conf + +/usr/sbin/squid -Nz +echo "[ENTRYPOINT] starting squid" +/usr/sbin/squid -f /etc/squid/squid.conf -NYC 1 diff --git a/docker/ssrf_proxy/docker-entrypoint.sh b/docker/ssrf_proxy/docker-entrypoint.sh index a19f9818b24..36c3a60a9f8 100755 --- a/docker/ssrf_proxy/docker-entrypoint.sh +++ b/docker/ssrf_proxy/docker-entrypoint.sh @@ -74,16 +74,22 @@ if [ -n "${SSRF_SANDBOX_PROXY_PORT:-}" ]; then } >> "$SANDBOX_PROXY_CONF" fi -# Replace environment variables in the template and output to the squid.conf -echo "[ENTRYPOINT] replacing environment variables in the template" -awk '{ - while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { - var = substr($0, RSTART+2, RLENGTH-3) - val = ENVIRON[var] - $0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH) - } - print -}' /etc/squid/squid.conf.template > /etc/squid/squid.conf +# Replace environment variables in a template file. +expand_env() { + awk '{ + while(match($0, /\${[A-Za-z_][A-Za-z_0-9]*}/)) { + var = substr($0, RSTART+2, RLENGTH-3) + val = ENVIRON[var] + $0 = substr($0, 1, RSTART-1) val substr($0, RSTART+RLENGTH) + } + print + }' "$1" +} + +# Replace environment variables in the templates and output to squid.conf +echo "[ENTRYPOINT] replacing environment variables in the templates" +expand_env /etc/squid/squid.conf.template > /etc/squid/squid.conf +expand_env /etc/squid/dify_common.conf.template > /etc/squid/dify_common.conf /usr/sbin/squid -Nz echo "[ENTRYPOINT] starting squid" diff --git a/docker/ssrf_proxy/squid-agent.conf.template b/docker/ssrf_proxy/squid-agent.conf.template new file mode 100644 index 00000000000..3a00d73dc85 --- /dev/null +++ b/docker/ssrf_proxy/squid-agent.conf.template @@ -0,0 +1,21 @@ +# Dedicated Squid config for the dify-agent local_sandbox SSRF proxy. +# All traffic on this proxy is restricted to: +# - agent_backend /agent-stub/* endpoints +# - Dify API /files/* endpoints (signed upload/download URLs) +# External internet is allowed; all other private-network destinations are denied. + +include /etc/squid/dify_common.conf + +acl dst_agent_backend dstdomain agent_backend +acl dst_dify_api dstdomain api +acl path_files urlpath_regex -i ^/files/ +acl path_agent_stub urlpath_regex -i ^/agent-stub/ + +http_port ${HTTP_PORT} + +http_access deny !Safe_ports +http_access deny CONNECT !SSL_ports +http_access allow dst_agent_backend path_agent_stub +http_access allow dst_dify_api path_files +http_access deny to_private_networks +http_access allow all diff --git a/docker/ssrf_proxy/squid-common.conf.template b/docker/ssrf_proxy/squid-common.conf.template new file mode 100644 index 00000000000..d3620dd0b8b --- /dev/null +++ b/docker/ssrf_proxy/squid-common.conf.template @@ -0,0 +1,90 @@ +# Shared Squid configuration used by both ssrf_proxy and agent_ssrf_proxy. + +################################## ACL Definitions ################################ +acl client_localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN) +acl client_localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN) +acl client_localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN) +acl client_localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines +acl client_localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN) +acl client_localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN) +acl client_localnet src fc00::/7 # RFC 4193 local private network range +acl client_localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines + +acl to_private_networks dst 0.0.0.0/8 +acl to_private_networks dst 10.0.0.0/8 +acl to_private_networks dst 100.64.0.0/10 +acl to_private_networks dst 127.0.0.0/8 +acl to_private_networks dst 169.254.0.0/16 +acl to_private_networks dst 172.16.0.0/12 +acl to_private_networks dst 192.168.0.0/16 +acl to_private_networks dst 224.0.0.0/4 +acl to_private_networks dst 240.0.0.0/4 +acl to_private_networks dst ::/128 +acl to_private_networks dst ::1/128 +acl to_private_networks dst ::ffff:0:0/96 # IPv4-mapped +acl to_private_networks dst ::/96 # deprecated IPv4-compatible +acl to_private_networks dst fc00::/7 +acl to_private_networks dst fe80::/10 + +acl SSL_ports port 443 +acl Safe_ports port 80 # http +acl Safe_ports port 21 # ftp +acl Safe_ports port 443 # https +acl Safe_ports port 70 # gopher +acl Safe_ports port 210 # wais +acl Safe_ports port 1025-65535 # unregistered ports +acl Safe_ports port 280 # http-mgmt +acl Safe_ports port 488 # gss-http +acl Safe_ports port 591 # filemaker +acl Safe_ports port 777 # multiling http +acl CONNECT method CONNECT + +################################## Common Parameters ################################ + +tcp_outgoing_address 0.0.0.0 + +################################## Proxy Server ################################ +coredump_dir ${COREDUMP_DIR} +refresh_pattern ^ftp: 1440 20% 10080 +refresh_pattern ^gopher: 1440 0% 1440 +refresh_pattern -i (/cgi-bin/|\?) 0 0% 0 +refresh_pattern \/(Packages|Sources)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern \/Release(|\.gpg)$ 0 0% 0 refresh-ims +refresh_pattern \/InRelease$ 0 0% 0 refresh-ims +refresh_pattern \/(Translation-.*)(|\.bz2|\.gz|\.xz)$ 0 0% 0 refresh-ims +refresh_pattern . 0 20% 4320 + +################################## Request Buffer ################################ +client_request_buffer_max_size 100 MB + +################################## Performance & Concurrency ############################### +max_filedescriptors 65536 +connect_timeout 30 seconds +request_timeout 2 minutes +read_timeout 2 minutes +client_lifetime 5 minutes +shutdown_lifetime 30 seconds + +server_persistent_connections on +client_persistent_connections on +persistent_request_timeout 30 seconds +pconn_timeout 1 minute + +client_db on +server_idle_pconn_timeout 2 minutes +client_idle_pconn_timeout 2 minutes + +quick_abort_min 16 KB +quick_abort_max 16 MB +quick_abort_pct 95 + +memory_cache_mode disk +cache_mem 256 MB +maximum_object_size_in_memory 512 KB + +dns_timeout 30 seconds +dns_retransmit_interval 5 seconds + +logformat dify_log %ts.%03tu %6tr %>a %Ss/%03>Hs %a %Ss/%03>Hs %/dev/null 2>&1 || true docker rm -f "$SANDBOX_CONTAINER_NAME" >/dev/null 2>&1 || true + docker rm -f "$AGENT_PROXY_CONTAINER_NAME" >/dev/null 2>&1 || true + docker rm -f "$API_CONTAINER_NAME" >/dev/null 2>&1 || true + docker rm -f "$AGENT_BACKEND_CONTAINER_NAME" >/dev/null 2>&1 || true docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true } @@ -106,6 +112,7 @@ docker run \ --entrypoint sh \ --network "$NETWORK_NAME" \ --volume "$ROOT_DIR/docker/ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template:ro" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template:ro" \ --volume "$ROOT_DIR/docker/ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh:ro" \ --env HTTP_PORT=3128 \ --env COREDUMP_DIR=/var/spool/squid \ @@ -141,3 +148,79 @@ if [[ "$RUN_PUBLIC_CHECK" == "true" ]]; then fi assert_sandbox_bridge_allowed "http://$CONTAINER_NAME:8194/health" + +# --------------------------------------------------------------------------- +# agent_ssrf_proxy tests +# --------------------------------------------------------------------------- + +# Mock api server: serves /files/* (200) and everything else (404). +docker run \ + --detach \ + --name "$API_CONTAINER_NAME" \ + --network "$NETWORK_NAME" \ + --network-alias api \ + "$CLIENT_IMAGE" \ + sh -c "mkdir -p /www/files && echo file-ok > /www/files/test && echo denied > /www/index.html && httpd -f -p 5001 -h /www" \ + >/dev/null + +# Mock agent_backend server: serves /agent-stub/* (200) and everything else (404). +docker run \ + --detach \ + --name "$AGENT_BACKEND_CONTAINER_NAME" \ + --network "$NETWORK_NAME" \ + --network-alias agent_backend \ + "$CLIENT_IMAGE" \ + sh -c "mkdir -p /www/agent-stub && echo stub-ok > /www/agent-stub/config && echo denied > /www/index.html && httpd -f -p 5050 -h /www" \ + >/dev/null + +docker run \ + --detach \ + --name "$AGENT_PROXY_CONTAINER_NAME" \ + --entrypoint sh \ + --network "$NETWORK_NAME" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/squid-agent.conf.template:/etc/squid/squid.conf.template:ro" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/squid-common.conf.template:/etc/squid/dify_common.conf.template:ro" \ + --volume "$ROOT_DIR/docker/ssrf_proxy/docker-agent-entrypoint.sh:/docker-entrypoint-mount.sh:ro" \ + --env HTTP_PORT=3128 \ + --env COREDUMP_DIR=/var/spool/squid \ + "$IMAGE" \ + -c "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh" \ + >/dev/null + +agent_proxy_url="http://$AGENT_PROXY_CONTAINER_NAME:3128" +for _ in {1..30}; do + agent_probe_status="$(http_code_for "$agent_proxy_url" "http://127.0.0.1:80/")" + if [[ -n "$agent_probe_status" ]]; then + break + fi + sleep 1 +done + +if [[ -z "${agent_probe_status:-}" ]]; then + echo "Agent SSRF proxy did not respond to probes." + docker logs "$AGENT_PROXY_CONTAINER_NAME" >&2 || true + exit 1 +fi + +# Private targets must be blocked. +assert_private_target_blocked "$agent_proxy_url" "http://127.0.0.1:80/" +assert_private_target_blocked "$agent_proxy_url" "http://169.254.169.254/latest/meta-data/" + +# agent_backend /agent-stub/* must be allowed. +assert_public_target_allowed "$agent_proxy_url" "http://agent_backend:5050/agent-stub/config" + +# agent_backend non-/agent-stub paths must be blocked (403 from Squid). +assert_private_target_blocked "$agent_proxy_url" "http://agent_backend:5050/index.html" + +# api /files/* must be allowed. +assert_public_target_allowed "$agent_proxy_url" "http://api:5001/files/test" + +# api non-/files paths must be blocked. +assert_private_target_blocked "$agent_proxy_url" "http://api:5001/index.html" + +# External internet must be allowed. +if [[ "$RUN_PUBLIC_CHECK" == "true" ]]; then + assert_public_target_allowed "$agent_proxy_url" "http://example.com/" +fi + +echo "All SSRF proxy tests passed." From 58e2bcbba12eeefe7e0cd2b895063fa9458062ff Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:22:37 +0800 Subject: [PATCH 030/531] fix(web): stop reporting workspace status (#39626) --- web/context/__tests__/console-bootstrap.spec.tsx | 4 +++- web/context/amplitude-identity-sync.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx index 61987812eb7..f1e9dce2a2b 100644 --- a/web/context/__tests__/console-bootstrap.spec.tsx +++ b/web/context/__tests__/console-bootstrap.spec.tsx @@ -552,7 +552,8 @@ describe('Console bootstrap', () => { }) await waitFor(() => { expect(setUserId).toHaveBeenCalledWith('user@example.com') - expect(setUserProperties).toHaveBeenCalledWith( + const properties = vi.mocked(setUserProperties).mock.calls.at(-1)?.[0] + expect(properties).toEqual( expect.objectContaining({ email: 'user@example.com', workspace_id: 'workspace-1', @@ -560,6 +561,7 @@ describe('Console bootstrap', () => { workspace_role: 'editor', }), ) + expect(properties).not.toHaveProperty('workspace_status') expect(flushRegistrationSuccess).toHaveBeenCalled() }) }) diff --git a/web/context/amplitude-identity-sync.ts b/web/context/amplitude-identity-sync.ts index c8684c5fd8d..abd7ac462a3 100644 --- a/web/context/amplitude-identity-sync.ts +++ b/web/context/amplitude-identity-sync.ts @@ -30,7 +30,6 @@ function buildAmplitudeProperties({ properties.workspace_id = currentWorkspace.id properties.workspace_name = currentWorkspace.name properties.workspace_plan = currentWorkspace.plan - properties.workspace_status = currentWorkspace.status properties.workspace_role = currentWorkspace.role } From e5d40336b38b285a143c4a9b03fc0a1983d4f87e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:15:13 +0800 Subject: [PATCH 031/531] fix(workflow): lazy-load Loro collaboration runtime (#39631) --- ...laboration-manager.logs-and-events.spec.ts | 2 + ...llaboration-manager.merge-behavior.test.ts | 2 + ...laboration-manager.runtime-loading.spec.ts | 88 +++++++++++++++++++ ...n-manager.socket-and-subscriptions.spec.ts | 3 + .../__tests__/collaboration-manager.test.ts | 2 + .../core/__tests__/test-crdt-runtime.ts | 7 ++ .../core/collaboration-manager.ts | 25 +++++- .../collaboration/core/crdt-runtime.ts | 21 +++++ 8 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts create mode 100644 web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts create mode 100644 web/app/components/workflow/collaboration/core/crdt-runtime.ts diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts index 80af37b671a..7ac65fcc8e3 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.logs-and-events.spec.ts @@ -6,6 +6,7 @@ import { LoroDoc } from 'loro-crdt' import { BlockEnum } from '@/app/components/workflow/types' import { CollaborationManager } from '../collaboration-manager' import { webSocketClient } from '../websocket-manager' +import { attachCrdtRuntime } from './test-crdt-runtime' type ReactFlowStore = { getState: () => { @@ -70,6 +71,7 @@ const createEdge = (id: string, source: string, target: string): Edge => ({ const setupManagerWithDoc = () => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const doc = new LoroDoc() const internals = getManagerInternals(manager) internals.doc = doc diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts index adc55768173..85b95c37c22 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.merge-behavior.test.ts @@ -3,6 +3,7 @@ import type { Node } from '@/app/components/workflow/types' import { LoroDoc } from 'loro-crdt/base64' import { BlockEnum } from '@/app/components/workflow/types' import { CollaborationManager } from '../collaboration-manager' +import { attachCrdtRuntime } from './test-crdt-runtime' const NODE_ID = 'node-1' const LLM_NODE_ID = 'llm-node' @@ -163,6 +164,7 @@ const getManagerInternals = (manager: CollaborationManager): CollaborationManage const getManager = (doc: LoroDoc) => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const internals = getManagerInternals(manager) internals.doc = doc internals.nodesMap = doc.getMap('nodes') diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts new file mode 100644 index 00000000000..7fdefbb6fc7 --- /dev/null +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts @@ -0,0 +1,88 @@ +import type { Socket } from 'socket.io-client' + +const loroModuleState = vi.hoisted(() => ({ evaluations: 0 })) + +vi.mock('loro-crdt', async (importOriginal) => { + loroModuleState.evaluations += 1 + return importOriginal() +}) + +const createMockSocket = (): Socket => + ({ + id: 'socket-runtime-loading', + connected: true, + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }) as unknown as Socket + +const loadCollaborationModules = async () => { + const [{ CollaborationManager }, { webSocketClient }] = await Promise.all([ + import('../collaboration-manager'), + import('../websocket-manager'), + ]) + + return { CollaborationManager, webSocketClient } +} + +describe('CollaborationManager CRDT runtime loading', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.resetModules() + loroModuleState.evaluations = 0 + }) + + it('does not evaluate Loro when the manager module is loaded', async () => { + const { CollaborationManager } = await loadCollaborationModules() + const manager = new CollaborationManager() + + expect(loroModuleState.evaluations).toBe(0) + expect(manager.isConnected()).toBe(false) + }) + + it('does not create connection state when the runtime fails to load', async () => { + const { CollaborationManager, webSocketClient } = await loadCollaborationModules() + const manager = new CollaborationManager() + const runtimeError = new Error('runtime-load-failed') + const loadRuntimeSpy = vi + .spyOn( + manager as unknown as { + loadCrdtRuntime: () => Promise<(typeof import('../crdt-runtime'))['crdtRuntime']> + }, + 'loadCrdtRuntime', + ) + .mockRejectedValue(runtimeError) + const connectSpy = vi.spyOn(webSocketClient, 'connect') + + await expect(manager.connect('app-runtime-failure')).rejects.toBe(runtimeError) + + expect(loadRuntimeSpy).toHaveBeenCalledTimes(1) + expect(connectSpy).not.toHaveBeenCalled() + expect(manager.isConnected()).toBe(false) + }) + + it('initializes one session for concurrent consumers of the same app', async () => { + const { CollaborationManager, webSocketClient } = await loadCollaborationModules() + const manager = new CollaborationManager() + const socket = createMockSocket() + const connectSpy = vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket) + const disconnectSpy = vi + .spyOn(webSocketClient, 'disconnect') + .mockImplementation(() => undefined) + + const [firstConnectionId, secondConnectionId] = await Promise.all([ + manager.connect('app-concurrent'), + manager.connect('app-concurrent'), + ]) + + expect(firstConnectionId).not.toBe(secondConnectionId) + expect(connectSpy).toHaveBeenCalledTimes(1) + expect(loroModuleState.evaluations).toBe(1) + + manager.disconnect(firstConnectionId) + expect(disconnectSpy).not.toHaveBeenCalled() + + manager.disconnect(secondConnectionId) + expect(disconnectSpy).toHaveBeenCalledWith('app-concurrent') + }) +}) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts index 4e3fa225dc9..5ecae6c355c 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts @@ -13,6 +13,7 @@ import { LoroDoc, LoroMap } from 'loro-crdt' import { BlockEnum } from '@/app/components/workflow/types' import { CollaborationManager } from '../collaboration-manager' import { webSocketClient } from '../websocket-manager' +import { attachCrdtRuntime } from './test-crdt-runtime' type ReactFlowStore = { getState: () => { @@ -147,6 +148,7 @@ const createMockSocket = (id = 'socket-1'): MockSocket => { const setupManagerWithDoc = () => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const doc = new LoroDoc() const internals = getManagerInternals(manager) internals.doc = doc @@ -1371,6 +1373,7 @@ describe('CollaborationManager socket and subscription behavior', () => { it('covers private guard branches for socket helpers and container migration', async () => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const internals = getManagerInternals(manager) const socket = createMockSocket('socket-private') const getSocketSpy = vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(null) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts index 1bf1958d4b1..4a957c9d1a6 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.test.ts @@ -8,6 +8,7 @@ import { LoroDoc } from 'loro-crdt/base64' import { Position } from 'reactflow' import { CollaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { BlockEnum } from '@/app/components/workflow/types' +import { attachCrdtRuntime } from './test-crdt-runtime' const NODE_ID = '1760342909316' @@ -247,6 +248,7 @@ const setupManager = (): { internals: CollaborationManagerInternals } => { const manager = new CollaborationManager() + attachCrdtRuntime(manager) const doc = new LoroDoc() const internals = getManagerInternals(manager) internals.doc = doc diff --git a/web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts b/web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts new file mode 100644 index 00000000000..852012bf06c --- /dev/null +++ b/web/app/components/workflow/collaboration/core/__tests__/test-crdt-runtime.ts @@ -0,0 +1,7 @@ +import type { CollaborationManager } from '../collaboration-manager' +import { crdtRuntime } from '../crdt-runtime' + +export const attachCrdtRuntime = (manager: CollaborationManager): void => { + const internals = manager as unknown as { crdtRuntime: typeof crdtRuntime } + internals.crdtRuntime = crdtRuntime +} diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 5b2c45a20af..02bbd7b7e13 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -1,6 +1,6 @@ 'use client' -import type { Value } from 'loro-crdt' +import type { LoroDoc, LoroList, LoroMap, UndoManager, Value } from 'loro-crdt' import type { Socket } from 'socket.io-client' import type { CommonNodeType, Edge, Node } from '../../types' import type { @@ -17,13 +17,14 @@ import type { WorkflowSyncRequest, WorkflowSyncResult, } from '../types/collaboration' +import type { CRDTProvider } from './crdt-provider' import { cloneDeep } from 'es-toolkit/object' import { isEqual } from 'es-toolkit/predicate' -import { LoroDoc, LoroList, LoroMap, UndoManager } from 'loro-crdt' -import { CRDTProvider } from './crdt-provider' import { EventEmitter } from './event-emitter' import { emitWithAuthGuard, webSocketClient } from './websocket-manager' +type CrdtRuntime = (typeof import('./crdt-runtime'))['crdtRuntime'] + type NodePanelPresenceEventData = { nodeId: string action: 'open' | 'close' @@ -154,6 +155,7 @@ const toUint8Array = (value: unknown): Uint8Array | null => { } export class CollaborationManager { + private crdtRuntime: CrdtRuntime | null = null private doc: LoroDoc | null = null private undoManager: UndoManager | null = null private provider: CRDTProvider | null = null @@ -259,6 +261,8 @@ export class CollaborationManager { private getNodeContainer(nodeId: string): LoroMap> { if (!this.nodesMap) throw new Error('Nodes map not initialized') + const { LoroMap } = this.getCrdtRuntime() + let container = this.nodesMap.get(nodeId) as unknown const isMapContainer = ( @@ -292,6 +296,7 @@ export class CollaborationManager { private ensureDataContainer( nodeContainer: LoroMap>, ): LoroMap> { + const { LoroMap } = this.getCrdtRuntime() let dataContainer = nodeContainer.get('data') as unknown if ( @@ -309,6 +314,7 @@ export class CollaborationManager { nodeContainer: LoroMap>, key: string, ): LoroList { + const { LoroList } = this.getCrdtRuntime() const dataContainer = this.ensureDataContainer(nodeContainer) let list = dataContainer.get(key) as unknown @@ -527,7 +533,18 @@ export class CollaborationManager { this.disconnect() } + private async loadCrdtRuntime(): Promise { + const { crdtRuntime } = await import('./crdt-runtime') + return crdtRuntime + } + + private getCrdtRuntime(): CrdtRuntime { + if (!this.crdtRuntime) throw new Error('CRDT runtime not initialized') + return this.crdtRuntime + } + private initializeCrdt(socket: Socket): void { + const { CRDTProvider, LoroDoc, UndoManager } = this.getCrdtRuntime() this.provider?.destroy() this.undoManager = null this.doc = new LoroDoc() @@ -622,6 +639,8 @@ export class CollaborationManager { } async connect(appId: string, reactFlowStore?: ReactFlowStore): Promise { + this.crdtRuntime ??= await this.loadCrdtRuntime() + const connectionId = Math.random().toString(36).substring(2, 11) this.activeConnections.add(connectionId) diff --git a/web/app/components/workflow/collaboration/core/crdt-runtime.ts b/web/app/components/workflow/collaboration/core/crdt-runtime.ts new file mode 100644 index 00000000000..6e38c26d406 --- /dev/null +++ b/web/app/components/workflow/collaboration/core/crdt-runtime.ts @@ -0,0 +1,21 @@ +'use client' + +import { LoroDoc, LoroList, LoroMap, UndoManager } from 'loro-crdt' +import { CRDTProvider } from './crdt-provider' + +/** + * Production code must load this module only through CollaborationManager.connect(). + * Importing either Loro or CRDTProvider from the manager would evaluate Loro's browser WASM + * loader on pages that only reference the manager. CRDTProvider belongs here because it also + * imports loro-crdt at runtime. + * + * TODO: Move graph sync, snapshots, and undo/redo behind an opaque CrdtGraphRuntime interface + * so CollaborationManager no longer depends on Loro constructors or types. + */ +export const crdtRuntime = { + CRDTProvider, + LoroDoc, + LoroList, + LoroMap, + UndoManager, +} From 26a43db6a4c23e0e42cbe02b3f92a7517b207a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Mon, 27 Jul 2026 15:34:25 +0800 Subject: [PATCH 032/531] fix(api): wait for workflow worker cleanup before task completion (#39614) --- .../app/apps/advanced_chat/app_generator.py | 43 ++++++++----- api/core/app/apps/base_app_generator.py | 29 +++++++++ .../app/apps/pipeline/pipeline_generator.py | 31 ++++++--- api/core/app/apps/workflow/app_generator.py | 31 ++++++--- .../app_generate/workflow_execute_task.py | 2 +- .../apps/advanced_chat/test_app_generator.py | 18 ++++++ .../apps/pipeline/test_pipeline_generator.py | 2 + .../core/app/apps/test_base_app_generator.py | 55 ++++++++++++++++ .../app/apps/test_workflow_app_generator.py | 20 ++++++ .../workflow/test_active_workflow_tasks.py | 52 +++++++++++++++ .../apps/workflow/test_app_generator_extra.py | 64 +++++++++++++++++++ 11 files changed, 310 insertions(+), 37 deletions(-) diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 3c677804028..912641b5e8d 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -616,23 +616,34 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): message_snapshot = MessageSnapshot.from_message(message) session.close() - # return response or stream generator - response = self._handle_advanced_chat_response( - application_generate_entity=application_generate_entity, - workflow=workflow_snapshot, - queue_manager=queue_manager, - conversation=conversation_snapshot, - message=message_snapshot, - user=user, - stream=stream, - draft_var_saver_factory=self._get_draft_var_saver_factory( - invoke_from, - account=user, - tenant_id=application_generate_entity.app_config.tenant_id, - ), - ) + try: + response = self._handle_advanced_chat_response( + application_generate_entity=application_generate_entity, + workflow=workflow_snapshot, + queue_manager=queue_manager, + conversation=conversation_snapshot, + message=message_snapshot, + user=user, + stream=stream, + draft_var_saver_factory=self._get_draft_var_saver_factory( + invoke_from, + account=user, + tenant_id=application_generate_entity.app_config.tenant_id, + ), + ) + converted_response = AdvancedChatAppGenerateResponseConverter.convert( + response=response, + invoke_from=invoke_from, + ) + except BaseException: + self._join_worker_thread(worker_thread) + raise - return AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from) + if isinstance(converted_response, Generator): + return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread) + + self._join_worker_thread(worker_thread) + return converted_response def _generate_worker( self, diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index 2762f99301d..71831bbb6a7 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -1,3 +1,5 @@ +import logging +import threading from collections.abc import Generator, Mapping, Sequence from contextlib import AbstractContextManager, nullcontext from typing import TYPE_CHECKING, Any, Union, final @@ -23,6 +25,10 @@ from services.workflow_draft_variable_service import DraftVariableSaver as Draft if TYPE_CHECKING: from graphon.variables.input_entities import VariableEntity +logger = logging.getLogger(__name__) + +_WORKER_THREAD_JOIN_TIMEOUT_SECONDS = 300 + @final class _DebuggerDraftVariableSaver: @@ -64,6 +70,29 @@ class _DebuggerDraftVariableSaver: class BaseAppGenerator: _file_access_controller: DatabaseFileAccessController = DatabaseFileAccessController() + @staticmethod + def _join_worker_thread(worker_thread: threading.Thread) -> None: + # Bound the wait so a leaked app worker cannot occupy an execution slot indefinitely. + worker_thread.join(timeout=_WORKER_THREAD_JOIN_TIMEOUT_SECONDS) + if worker_thread.is_alive(): + logger.warning( + "Possible app worker thread leak: thread_name=%s timeout_seconds=%s; " + "continuing without waiting further to avoid occupying an execution slot indefinitely", + worker_thread.name, + _WORKER_THREAD_JOIN_TIMEOUT_SECONDS, + ) + + @staticmethod + def _wrap_stream_with_worker_thread_join[ResponseT]( + response_stream: Generator[ResponseT, None, None], + worker_thread: threading.Thread, + ) -> Generator[ResponseT, None, None]: + """Keep the producer owned by the response stream until both finish.""" + try: + yield from response_stream + finally: + BaseAppGenerator._join_worker_thread(worker_thread) + @staticmethod def _bind_file_access_scope( *, diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 3eb93e7c08a..9e97ce836ca 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -351,17 +351,28 @@ class PipelineGenerator(BaseAppGenerator): user, tenant_id=pipeline.tenant_id, ) - # return response or stream generator - response = self._handle_response( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - user=user, - stream=streaming, - draft_var_saver_factory=draft_var_saver_factory, - ) + try: + response = self._handle_response( + application_generate_entity=application_generate_entity, + workflow=workflow, + queue_manager=queue_manager, + user=user, + stream=streaming, + draft_var_saver_factory=draft_var_saver_factory, + ) + converted_response = WorkflowAppGenerateResponseConverter.convert( + response=response, + invoke_from=invoke_from, + ) + except BaseException: + self._join_worker_thread(worker_thread) + raise - return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from) + if isinstance(converted_response, Generator): + return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread) + + self._join_worker_thread(worker_thread) + return converted_response def single_iteration_generate( self, diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index fb5393d7730..db1c49cd6d0 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -405,17 +405,28 @@ class WorkflowAppGenerator(BaseAppGenerator): tenant_id=app_model.tenant_id, ) - # return response or stream generator - response = self._handle_response( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - user=user, - draft_var_saver_factory=draft_var_saver_factory, - stream=streaming, - ) + try: + response = self._handle_response( + application_generate_entity=application_generate_entity, + workflow=workflow, + queue_manager=queue_manager, + user=user, + draft_var_saver_factory=draft_var_saver_factory, + stream=streaming, + ) + converted_response = WorkflowAppGenerateResponseConverter.convert( + response=response, + invoke_from=invoke_from, + ) + except BaseException: + self._join_worker_thread(worker_thread) + raise - return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from) + if isinstance(converted_response, Generator): + return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread) + + self._join_worker_thread(worker_thread) + return converted_response def single_iteration_generate( self, diff --git a/api/tasks/app_generate/workflow_execute_task.py b/api/tasks/app_generate/workflow_execute_task.py index d76066a8aa7..9bc09bac781 100644 --- a/api/tasks/app_generate/workflow_execute_task.py +++ b/api/tasks/app_generate/workflow_execute_task.py @@ -457,7 +457,7 @@ def _publish_streaming_response( @shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE) def workflow_based_app_execution_task( payload: str, -) -> Generator[Mapping[str, Any] | str, None, None] | Mapping[str, Any] | None: +) -> Mapping[str, Any] | None: exec_params = AppExecutionParams.model_validate_json(payload) logger.info("workflow_based_app_execution_task run with params: %s", exec_params) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index f347a5fae7e..004906a5de3 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -442,6 +442,13 @@ class TestAdvancedChatAppGeneratorInternals: def start(self): thread_data["started"] = True + def join(self, timeout): + thread_data["joined"] = True + thread_data["join_timeout"] = timeout + + def is_alive(self): + return False + monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session) @@ -475,6 +482,8 @@ class TestAdvancedChatAppGeneratorInternals: assert response["response"] == {"raw": True} assert thread_data["started"] is True + assert thread_data["joined"] is True + assert thread_data["join_timeout"] == 300 assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"] assert generator._dialogue_count == 3 assert init_records.call_args.kwargs["session"] is db_session @@ -542,6 +551,13 @@ class TestAdvancedChatAppGeneratorInternals: def start(self): thread_data["started"] = True + def join(self, timeout): + thread_data["joined"] = True + thread_data["join_timeout"] = timeout + + def is_alive(self): + return False + monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) monkeypatch.setattr( "core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session) @@ -574,6 +590,8 @@ class TestAdvancedChatAppGeneratorInternals: init_records.assert_not_called() get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session) assert thread_data["started"] is True + assert thread_data["joined"] is True + assert thread_data["join_timeout"] == 300 db_session.commit.assert_not_called() db_session.refresh.assert_not_called() db_session.close.assert_called_once() diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py index f2b8179160b..9000cc94ed4 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py @@ -435,6 +435,7 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture): mocker.patch.object(module, "PipelineQueueManager", return_value=queue_manager) worker_thread = MagicMock() + worker_thread.is_alive.return_value = False mocker.patch.object(module.threading, "Thread", return_value=worker_thread) mocker.patch.object(generator, "_get_draft_var_saver_factory", return_value=MagicMock()) @@ -461,6 +462,7 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture): ) assert result == "converted" + worker_thread.join.assert_called_once_with(timeout=300) def test_single_iteration_generate_validates_inputs(generator, mocker: MockerFixture): diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py index 8e7468bb0b8..fe07e420198 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py @@ -1,3 +1,6 @@ +import logging +from unittest.mock import Mock + import pytest from core.app.apps.base_app_generator import BaseAppGenerator @@ -369,6 +372,58 @@ def test_validate_inputs_optional_file_with_empty_string_ignores_default(): class TestBaseAppGeneratorExtras: + def test_wrap_stream_joins_worker_after_stream_exhaustion(self): + base_app_generator = BaseAppGenerator() + worker_thread = Mock() + worker_thread.is_alive.return_value = False + + def response_stream(): + yield {"event": "workflow_finished"} + + managed_stream = base_app_generator._wrap_stream_with_worker_thread_join( + response_stream(), + worker_thread, + ) + + assert next(managed_stream) == {"event": "workflow_finished"} + worker_thread.join.assert_not_called() + + with pytest.raises(StopIteration): + next(managed_stream) + + worker_thread.join.assert_called_once_with(timeout=300) + + def test_wrap_stream_joins_worker_when_stream_closes(self): + base_app_generator = BaseAppGenerator() + worker_thread = Mock() + worker_thread.is_alive.return_value = False + + def response_stream(): + yield {"event": "workflow_started"} + yield {"event": "workflow_finished"} + + managed_stream = base_app_generator._wrap_stream_with_worker_thread_join( + response_stream(), + worker_thread, + ) + + assert next(managed_stream) == {"event": "workflow_started"} + managed_stream.close() + + worker_thread.join.assert_called_once_with(timeout=300) + + def test_join_worker_thread_warns_when_thread_remains_alive(self, caplog: pytest.LogCaptureFixture): + worker_thread = Mock() + worker_thread.name = "leaked-app-worker" + worker_thread.is_alive.return_value = True + + with caplog.at_level(logging.WARNING, logger="core.app.apps.base_app_generator"): + BaseAppGenerator._join_worker_thread(worker_thread) + + worker_thread.join.assert_called_once_with(timeout=300) + assert "Possible app worker thread leak" in caplog.text + assert "leaked-app-worker" in caplog.text + def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest.MonkeyPatch): base_app_generator = BaseAppGenerator() diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 8f5cb2b8115..8fc8959cd23 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -211,6 +211,13 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture): def start(self): return None + def join(self, timeout): + worker_kwargs["joined"] = True + worker_kwargs["join_timeout"] = timeout + + def is_alive(self): + return False + mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", DummyThread) app_model = SimpleNamespace(mode="workflow", tenant_id="tenant") @@ -244,6 +251,8 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture): assert result == "converted" assert worker_kwargs["kwargs"]["graph_engine_layers"] == ("base-layer", pause_layer) assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state + assert worker_kwargs["joined"] is True + assert worker_kwargs["join_timeout"] == 300 assert draft_saver_factory.call_args.kwargs["tenant_id"] == app_model.tenant_id @@ -286,6 +295,8 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture): mocker.patch("core.app.apps.workflow.app_generator.WorkflowAppRunner", side_effect=runner_ctor) + worker_lifecycle: dict[str, bool] = {} + class ImmediateThread: def __init__(self, target, kwargs): target(**kwargs) @@ -293,6 +304,13 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture): def start(self): return None + def join(self, timeout): + worker_lifecycle["joined"] = True + worker_lifecycle["join_timeout"] = timeout + + def is_alive(self): + return False + mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", ImmediateThread) mocker.patch( @@ -331,5 +349,7 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture): ) assert result == "raw-response" + assert worker_lifecycle["joined"] is True + assert worker_lifecycle["join_timeout"] == 300 runner_instance.run.assert_called_once() queue_manager.graph_runtime_state = runtime_state diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py b/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py index c50b16533ff..769dd0c092d 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py @@ -1,5 +1,9 @@ +import threading +from collections.abc import Generator + import pytest +from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.workflow.active_workflow_tasks import ( active_workflow_task, get_active_workflow_task_count, @@ -28,3 +32,51 @@ def test_active_workflow_task_rejects_duplicate_task_id() -> None: with pytest.raises(ValueError, match="already active"): with active_workflow_task("task-a"): pass + + +def test_managed_stream_waits_for_active_worker_cleanup() -> None: + worker_started = threading.Event() + release_worker = threading.Event() + stream_exhausted = threading.Event() + consumer_finished = threading.Event() + consumer_errors: list[BaseException] = [] + + def run_worker() -> None: + with active_workflow_task("task-a"): + worker_started.set() + release_worker.wait() + + def response_stream() -> Generator[dict[str, str], None, None]: + yield {"event": "workflow_finished"} + stream_exhausted.set() + + worker_thread = threading.Thread(target=run_worker) + worker_thread.start() + assert worker_started.wait(timeout=2) + + managed_stream = BaseAppGenerator._wrap_stream_with_worker_thread_join(response_stream(), worker_thread) + assert next(managed_stream) == {"event": "workflow_finished"} + + def finish_stream() -> None: + try: + list(managed_stream) + except BaseException as exc: + consumer_errors.append(exc) + finally: + consumer_finished.set() + + consumer_thread = threading.Thread(target=finish_stream) + consumer_thread.start() + try: + assert stream_exhausted.wait(timeout=2) + assert not consumer_finished.is_set() + assert get_active_workflow_task_count() == 1 + finally: + release_worker.set() + consumer_thread.join(timeout=2) + worker_thread.join(timeout=2) + + assert not consumer_thread.is_alive() + assert not worker_thread.is_alive() + assert consumer_errors == [] + assert get_active_workflow_task_count() == 0 diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py index 3509c349aef..279b886cdf0 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py @@ -15,6 +15,70 @@ from models.model import AppMode class TestWorkflowAppGeneratorValidation: + def test_generate_stream_joins_worker_after_response_exhaustion(self, monkeypatch: pytest.MonkeyPatch): + generator = WorkflowAppGenerator() + worker_thread = Mock() + worker_thread.is_alive.return_value = False + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.WORKFLOW, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + application_generate_entity = WorkflowAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + files=[], + user_id="user", + stream=True, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + ) + + def response_stream(): + yield {"event": "workflow_finished"} + + monkeypatch.setattr(generator, "_bind_file_access_scope", lambda **kwargs: contextlib.nullcontext()) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppQueueManager", + lambda **kwargs: SimpleNamespace(**kwargs), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.current_app", + SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")), + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.contextvars.copy_context", lambda: "ctx") + monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", lambda **kwargs: worker_thread) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.db", + SimpleNamespace(session=SimpleNamespace(close=Mock())), + ) + monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") + monkeypatch.setattr(generator, "_handle_response", lambda **kwargs: response_stream()) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert", + lambda response, invoke_from: response, + ) + + managed_stream = generator._generate( + app_model=SimpleNamespace(mode=AppMode.WORKFLOW, tenant_id="tenant"), + workflow=SimpleNamespace(id="workflow-id"), + user=SimpleNamespace(id="user"), + application_generate_entity=application_generate_entity, + invoke_from=InvokeFrom.WEB_APP, + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + streaming=True, + ) + + worker_thread.start.assert_called_once_with() + worker_thread.join.assert_not_called() + assert list(managed_stream) == [{"event": "workflow_finished"}] + worker_thread.join.assert_called_once_with(timeout=300) + def test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow(self): workflow = SimpleNamespace(kind_or_standard="workflow") session = SimpleNamespace(scalar=Mock()) From 4313d2388938d40052c283bdaacb2d1439c3abb8 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:48:35 +0800 Subject: [PATCH 033/531] perf(web): lazy-load Home creation modals (#39634) --- web/app/components/explore/app-list/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index b8ec1a132ce..ed564cef852 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -15,10 +15,8 @@ import { useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' import AppCard from '@/app/components/explore/app-card' import { Banner } from '@/app/components/explore/banner/banner' -import CreateAppModal from '@/app/components/explore/create-app-modal' import { getStepByStepTourPermissionVariant, trackStepByStepTourEvent, @@ -50,6 +48,11 @@ import { ExploreHomeSkeleton } from './loading-skeletons' import s from './style.module.css' const TryApp = dynamic(() => import('../try-app'), { ssr: false }) +const CreateAppModal = dynamic(() => import('../create-app-modal'), { ssr: false }) +const DSLConfirmModal = dynamic( + () => import('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'), + { ssr: false }, +) type ExploreAppListData = { categories: string[] From b3774bfe1cff7fd92ca29010fa74b5c08b6ec5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Mon, 27 Jul 2026 16:00:39 +0800 Subject: [PATCH 034/531] perf: add lightweight recent apps endpoint (#39625) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/app.py | 77 ++++ api/openapi/markdown/console-openapi.md | 39 ++ api/services/app_service.py | 84 ++++ .../console/app/test_app_response_models.py | 115 +++++ .../unit_tests/services/test_app_service.py | 94 ++++- .../generated/api/console/apps/orpc.gen.ts | 393 ++++++++++-------- .../generated/api/console/apps/types.gen.ts | 50 +++ .../generated/api/console/apps/zod.gen.ts | 56 +++ .../explore/app-list/__tests__/index.spec.tsx | 99 +++-- .../app-list/explore-recommendations.tsx | 4 +- web/app/components/explore/app-list/index.tsx | 9 +- .../continue-work/__tests__/item.spec.tsx | 30 +- .../explore/continue-work/index.tsx | 4 +- .../components/explore/continue-work/item.tsx | 8 +- 14 files changed, 806 insertions(+), 256 deletions(-) diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 701fa366d81..d8b3c2e4256 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -58,6 +58,7 @@ from services.app_service import ( AppResponseView, AppService, CreateAppParams, + RecentAppMode, StarredAppListParams, ) from services.enterprise import rbac_service as enterprise_rbac_service @@ -139,6 +140,10 @@ class AppListBaseQuery(BaseModel): raise ValueError("Invalid UUID format in creator_ids.") from exc +class RecentAppListQuery(BaseModel): + limit: int = Field(default=8, ge=1, le=8, description="Number of recently modified apps to return (1-8)") + + class AppListQuery(AppListBaseQuery): pass @@ -411,6 +416,33 @@ class AppPartial(AppResponseModel): return to_timestamp(value) +class RecentAppResponse(ResponseModel): + id: str + name: str + icon_type: IconType | None = None + icon: str | None = None + icon_background: str | None = None + mode: RecentAppMode + author_name: str | None = None + updated_at: int + permission_keys: list[str] = Field(default_factory=list) + maintainer: str | None = None + + @computed_field(return_type=str | None) # type: ignore[prop-decorator] + @property + def icon_url(self) -> str | None: + return build_icon_url(self.icon_type, self.icon) + + @field_validator("updated_at", mode="before") + @classmethod + def _normalize_timestamp(cls, value: datetime | int) -> int: + return to_timestamp(value) + + +class RecentAppListResponse(ResponseModel): + data: list[RecentAppResponse] + + class AppDetail(AppResponseModel): id: str name: str @@ -575,6 +607,8 @@ register_schema_models( register_response_schema_models( console_ns, AppPartial, + RecentAppResponse, + RecentAppListResponse, AppDetailWithSite, AppPagination, ) @@ -699,6 +733,49 @@ class AppListApi(Resource): return app_detail.model_dump(mode="json"), 201 +@console_ns.route("/apps/recent") +class RecentAppListApi(Resource): + @console_ns.doc("list_recent_apps") + @console_ns.doc(description="Get recently modified apps for the home Continue Work section") + @console_ns.doc(params=query_params_from_model(RecentAppListQuery)) + @console_ns.response(200, "Success", console_ns.models[RecentAppListResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @enterprise_license_required + @with_session(write=False) + @with_current_user_id + @with_current_tenant_id + def get(self, current_tenant_id: str, current_user_id: str, session: Session): + """Return the lightweight app cards needed by the Explore home page.""" + args = query_params_from_request(RecentAppListQuery) + params = AppListParams(limit=args.limit) + + permissions = enterprise_rbac_service.RBACService.MyPermissions.get( + current_tenant_id, + current_user_id, + session=session, + ) + if dify_config.RBAC_ENABLED: + access_filter = resolve_app_access_filter( + current_tenant_id, + current_user_id, + session=session, + permissions=permissions, + ) + access_filter.apply_to_params(params) + + recent_apps = AppService().get_recent_apps(current_user_id, current_tenant_id, params, session) + permission_keys_map = permissions.app.permission_keys_by_resource_ids([app.id for app in recent_apps]) + response_items = [ + RecentAppResponse.model_validate(app, from_attributes=True).model_copy( + update={"permission_keys": permission_keys_map.get(app.id, [])} + ) + for app in recent_apps + ] + return dump_response(RecentAppListResponse, {"data": response_items}), 200 + + @console_ns.route("/apps/starred") class StarredAppListApi(Resource): @console_ns.doc("list_starred_apps") diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 34478b71b7c..e4d46101bb3 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -1672,6 +1672,23 @@ Create a new application | 200 | Import confirmed | **application/json**: [Import](#import)
| | 400 | Import failed | **application/json**: [Import](#import)
| +### [GET] /apps/recent +**Return the lightweight app cards needed by the Explore home page** + +Get recently modified apps for the home Continue Work section + +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | Number of recently modified apps to return (1-8) | No | integer,
**Default:** 8 | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [RecentAppListResponse](#recentapplistresponse)
| + ### [GET] /apps/starred Get applications starred by the current account @@ -21018,6 +21035,28 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs. | result | string | | Yes | | updated_at | integer | | Yes | +#### RecentAppListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [RecentAppResponse](#recentappresponse) ] | | Yes | + +#### RecentAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author_name | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | [IconType](#icontype) | | No | +| icon_url | string | | Yes | +| id | string | | Yes | +| maintainer | string | | No | +| mode | string,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | *Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | +| name | string | | Yes | +| permission_keys | [ string ] | | No | +| updated_at | integer | | Yes | + #### RecommendedAppDetailNullableResponse | Name | Type | Description | Required | diff --git a/api/services/app_service.py b/api/services/app_service.py index 3c32d1984e2..a858163e2fb 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -1,6 +1,7 @@ import json import logging from collections.abc import Sequence +from dataclasses import dataclass from datetime import datetime from typing import Any, Literal, NotRequired, TypedDict, cast, override @@ -41,6 +42,20 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t logger = logging.getLogger(__name__) AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"] +RecentAppMode = Literal[ + AppMode.COMPLETION, + AppMode.WORKFLOW, + AppMode.CHAT, + AppMode.ADVANCED_CHAT, + AppMode.AGENT_CHAT, +] +RECENT_APP_MODES: tuple[RecentAppMode, ...] = ( + AppMode.COMPLETION, + AppMode.WORKFLOW, + AppMode.CHAT, + AppMode.ADVANCED_CHAT, + AppMode.AGENT_CHAT, +) class AppListBaseParams(BaseModel): @@ -65,6 +80,19 @@ class StarredAppListParams(AppListBaseParams): pass +@dataclass(frozen=True) +class RecentAppListItem: + id: str + name: str + icon_type: IconType | None + icon: str | None + icon_background: str | None + mode: RecentAppMode + author_name: str | None + updated_at: datetime + maintainer: str | None + + class CreateAppParams(BaseModel): name: str = Field(min_length=1) description: str | None = None @@ -323,6 +351,62 @@ class AppService: return app_models + def get_recent_apps( + self, + user_id: str, + tenant_id: str, + params: AppListParams, + session: Session, + ) -> list[RecentAppListItem]: + """Return recently modified apps as one lightweight, non-paginated projection.""" + filters = self._build_app_list_filters(user_id, tenant_id, params, session) + if not filters: + return [] + + stmt = ( + sa.select( + App.id, + App.name, + App.icon_type, + App.icon, + App.icon_background, + App.mode, + Account.name.label("author_name"), + App.updated_at, + App.maintainer, + ) + .outerjoin(Account, Account.id == App.created_by) + .where(*filters, App.mode.in_(RECENT_APP_MODES)) + .order_by(App.updated_at.desc()) + .limit(params.limit) + ) + rows = session.execute(stmt).all() + + return [ + RecentAppListItem( + id=str(app_id), + name=name, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, + mode=cast(RecentAppMode, mode), + author_name=author_name, + updated_at=updated_at, + maintainer=maintainer, + ) + for ( + app_id, + name, + icon_type, + icon, + icon_background, + mode, + author_name, + updated_at, + maintainer, + ) in rows + ] + def get_paginate_starred_apps( self, user_id: str, diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 6b1dce534ed..b5631b4c1d1 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -708,6 +708,121 @@ def test_app_list_api_attaches_permission_keys(app, app_module): assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"] +def test_recent_app_list_api_returns_only_home_card_fields(app, app_module): + method = app_module.RecentAppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + recent_app = SimpleNamespace( + id="app-1", + name="Recent App", + icon_type="emoji", + icon="🚀", + icon_background="#FFFFFF", + mode="chat", + author_name="Recent Author", + updated_at=_ts(15), + maintainer="acct-1", + ) + get_recent_apps = MagicMock(return_value=[recent_app]) + + with app.test_request_context("/apps/recent?limit=8"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", False) + monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse( + app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot( + overrides=[ + app_module.enterprise_rbac_service.ResourcePermissionKeys( + resource_id="app-1", + permission_keys=["app.acl.monitor"], + ) + ] + ) + ), + ) + + resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock()) + + assert status == 200 + assert resp == { + "data": [ + { + "id": "app-1", + "name": "Recent App", + "icon_type": "emoji", + "icon": "🚀", + "icon_background": "#FFFFFF", + "mode": "chat", + "author_name": "Recent Author", + "updated_at": int(_ts(15).timestamp()), + "permission_keys": ["app.acl.monitor"], + "maintainer": "acct-1", + "icon_url": None, + } + ] + } + params = get_recent_apps.call_args.args[2] + assert params.limit == 8 + assert "total" not in resp + assert "description" not in resp["data"][0] + assert "tags" not in resp["data"][0] + assert "workflow" not in resp["data"][0] + + +@pytest.mark.parametrize("mode", ["channel", "rag-pipeline", "agent"]) +def test_recent_app_response_rejects_non_home_app_modes(app_module, mode: str) -> None: + with pytest.raises(ValidationError): + app_module.RecentAppResponse.model_validate( + { + "id": "app-1", + "name": "Recent App", + "mode": mode, + "updated_at": _ts(), + } + ) + + +def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module): + method = app_module.RecentAppListApi.get + while hasattr(method, "__wrapped__"): + method = method.__wrapped__ + + get_recent_apps = MagicMock(return_value=[]) + with app.test_request_context("/apps/recent"): + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(dify_config, "RBAC_ENABLED", True) + monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.MyPermissions, + "get", + lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse( + workspace=app_module.enterprise_rbac_service.WorkspacePermissionSnapshot( + permission_keys=["app.create_and_management"] + ) + ), + ) + monkeypatch.setattr( + app_module.enterprise_rbac_service.RBACService.AppAccess, + "whitelist_resources", + lambda tenant_id, account_id: SimpleNamespace( + unrestricted=False, + resource_ids=["app-shared"], + ), + ) + + resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock()) + + assert status == 200 + assert resp == {"data": []} + params = get_recent_apps.call_args.args[2] + assert params.accessible_app_ids == ["app-shared"] + assert params.include_own_apps is True + + def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(app, app_module): method = app_module.AppListApi.get while hasattr(method, "__wrapped__"): diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index 9707855a6d7..1e2d033e6ef 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -1,19 +1,23 @@ from __future__ import annotations from collections.abc import Callable +from datetime import datetime from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock, patch +from uuid import uuid4 import pytest +from sqlalchemy import event from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session from graphon.model_runtime.entities.model_entities import ModelType from models import Account -from models.model import App, AppMode, AppModelConfig +from models.model import App, AppMode, AppModelConfig, IconType from models.workflow import Workflow from services.agent.errors import AgentNameConflictError -from services.app_service import AppService, CreateAppParams +from services.app_service import AppListParams, AppService, CreateAppParams class TestCreateAppTransactionBoundary: @@ -236,6 +240,92 @@ class TestOpenapiVisibilityHelpers: mock_session.execute.assert_called_once() +@pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True) +def test_get_recent_apps_uses_one_tenant_scoped_projection_query(sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + other_tenant_id = str(uuid4()) + account = Account(name="Recent Apps Author", email="recent-apps@example.com") + sqlite_session.add(account) + sqlite_session.flush() + + def create_app(*, name: str, tenant_id: str, updated_at: datetime, mode: AppMode = AppMode.CHAT) -> App: + app = App() + app.id = str(uuid4()) + app.tenant_id = tenant_id + app.name = name + app.description = "" + app.mode = mode + app.icon_type = IconType.EMOJI + app.icon = "🚀" + app.icon_background = "#FFFFFF" + app.enable_site = False + app.enable_api = False + app.created_by = account.id + app.maintainer = account.id + app.created_at = updated_at + app.updated_at = updated_at + app.use_icon_as_answer_icon = False + return app + + newest = create_app(name="Newest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 3)) + legacy_agent = AppModelConfig(app_id=newest.id) + legacy_agent.agent_mode = '{"enabled": true, "strategy": "react"}' + newest.app_model_config_id = legacy_agent.id + second = create_app( + name="Second", + tenant_id=tenant_id, + updated_at=datetime(2026, 7, 2), + mode=AppMode.WORKFLOW, + ) + second.icon_type = None + second.icon = None + second.icon_background = None + second.created_by = None + second.maintainer = None + channel = create_app( + name="Channel", + tenant_id=tenant_id, + updated_at=datetime(2026, 7, 5), + mode=AppMode.CHANNEL, + ) + rag_pipeline = create_app( + name="RAG Pipeline", + tenant_id=tenant_id, + updated_at=datetime(2026, 7, 4), + mode=AppMode.RAG_PIPELINE, + ) + oldest = create_app(name="Oldest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 1)) + foreign = create_app(name="Foreign", tenant_id=other_tenant_id, updated_at=datetime(2026, 7, 4)) + sqlite_session.add_all([newest, legacy_agent, second, channel, rag_pipeline, oldest, foreign]) + sqlite_session.commit() + + statements: list[str] = [] + bind = sqlite_session.get_bind() + + def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(bind, "before_cursor_execute", record_sql) + try: + recent_apps = AppService().get_recent_apps( + account.id, + tenant_id, + AppListParams(limit=2), + sqlite_session, + ) + finally: + event.remove(bind, "before_cursor_execute", record_sql) + + assert [(app.name, app.mode, app.icon_type, app.author_name, app.maintainer) for app in recent_apps] == [ + ("Newest", AppMode.CHAT, IconType.EMOJI, "Recent Apps Author", account.id), + ("Second", AppMode.WORKFLOW, None, None, None), + ] + select_statements = [statement for statement in statements if statement.lstrip().upper().startswith("SELECT")] + assert len(select_statements) == 1 + assert "count(" not in select_statements[0].lower() + assert "app_model_configs" not in select_statements[0].lower() + + class TestAppMeta: def test_loads_workflow_with_caller_session(self): session = MagicMock() diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index d479394c028..b84db3cbb4e 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -277,6 +277,8 @@ import { zGetAppsImportsByAppIdCheckDependenciesPath, zGetAppsImportsByAppIdCheckDependenciesResponse, zGetAppsQuery, + zGetAppsRecentQuery, + zGetAppsRecentResponse, zGetAppsResponse, zGetAppsStarredQuery, zGetAppsStarredResponse, @@ -551,9 +553,31 @@ export const imports = { } /** - * Get applications starred by the current account + * Return the lightweight app cards needed by the Explore home page + * + * Get recently modified apps for the home Continue Work section */ export const get2 = oc + .route({ + description: 'Get recently modified apps for the home Continue Work section', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsRecent', + path: '/apps/recent', + summary: 'Return the lightweight app cards needed by the Explore home page', + tags: ['console'], + }) + .input(z.object({ query: zGetAppsRecentQuery.optional() })) + .output(zGetAppsRecentResponse) + +export const recent = { + get: get2, +} + +/** + * Get applications starred by the current account + */ +export const get3 = oc .route({ description: 'Get applications starred by the current account', inputStructure: 'detailed', @@ -566,7 +590,7 @@ export const get2 = oc .output(zGetAppsStarredResponse) export const starred = { - get: get2, + get: get3, } /** @@ -597,7 +621,7 @@ export const workflows = { * * Get advanced chat workflow runs count statistics */ -export const get3 = oc +export const get4 = oc .route({ description: 'Get advanced chat workflow runs count statistics', inputStructure: 'detailed', @@ -616,7 +640,7 @@ export const get3 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsCountResponse) export const count = { - get: get3, + get: get4, } /** @@ -624,7 +648,7 @@ export const count = { * * Get advanced chat workflow run list */ -export const get4 = oc +export const get5 = oc .route({ description: 'Get advanced chat workflow run list', inputStructure: 'detailed', @@ -643,7 +667,7 @@ export const get4 = oc .output(zGetAppsByAppIdAdvancedChatWorkflowRunsResponse) export const workflowRuns = { - get: get4, + get: get5, count, } @@ -839,7 +863,7 @@ export const advancedChat = { workflows: workflows2, } -export const get5 = oc +export const get6 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -856,10 +880,10 @@ export const get5 = oc .output(zGetAppsByAppIdAgentConfigFilesByNameDownloadResponse) export const download = { - get: get5, + get: get6, } -export const get6 = oc +export const get7 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -876,7 +900,7 @@ export const get6 = oc .output(zGetAppsByAppIdAgentConfigFilesByNamePreviewResponse) export const preview2 = { - get: get6, + get: get7, } export const delete_ = oc @@ -901,7 +925,7 @@ export const byName = { preview: preview2, } -export const get7 = oc +export const get8 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -936,12 +960,12 @@ export const post9 = oc .output(zPostAppsByAppIdAgentConfigFilesResponse) export const files = { - get: get7, + get: get8, post: post9, byName, } -export const get8 = oc +export const get9 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -958,7 +982,7 @@ export const get8 = oc .output(zGetAppsByAppIdAgentConfigManifestResponse) export const manifest = { - get: get8, + get: get9, } export const post10 = oc @@ -983,7 +1007,7 @@ export const upload = { post: post10, } -export const get9 = oc +export const get10 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1000,10 +1024,10 @@ export const get9 = oc .output(zGetAppsByAppIdAgentConfigSkillsByNameDownloadResponse) export const download2 = { - get: get9, + get: get10, } -export const get10 = oc +export const get11 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1015,10 +1039,10 @@ export const get10 = oc .output(zGetAppsByAppIdAgentConfigSkillsByNameFilesContentResponse) export const content = { - get: get10, + get: get11, } -export const get11 = oc +export const get12 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1035,10 +1059,10 @@ export const get11 = oc .output(zGetAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse) export const download3 = { - get: get11, + get: get12, } -export const get12 = oc +export const get13 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1055,7 +1079,7 @@ export const get12 = oc .output(zGetAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse) export const preview3 = { - get: get12, + get: get13, } export const files2 = { @@ -1064,7 +1088,7 @@ export const files2 = { preview: preview3, } -export const get13 = oc +export const get14 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1081,7 +1105,7 @@ export const get13 = oc .output(zGetAppsByAppIdAgentConfigSkillsByNameInspectResponse) export const inspect = { - get: get13, + get: get14, } export const delete2 = oc @@ -1107,7 +1131,7 @@ export const byName2 = { inspect, } -export const get14 = oc +export const get15 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -1124,7 +1148,7 @@ export const get14 = oc .output(zGetAppsByAppIdAgentConfigSkillsResponse) export const skills = { - get: get14, + get: get15, upload, byName: byName2, } @@ -1138,7 +1162,7 @@ export const config = { /** * Time-limited external signed URL for one drive value (no streaming proxy) */ -export const get15 = oc +export const get16 = oc .route({ description: 'Time-limited external signed URL for one drive value (no streaming proxy)', inputStructure: 'detailed', @@ -1156,13 +1180,13 @@ export const get15 = oc .output(zGetAppsByAppIdAgentDriveFilesDownloadResponse) export const download4 = { - get: get15, + get: get16, } /** * Truncated text preview of one drive value (binary-safe; SKILL.md is the main case) */ -export const get16 = oc +export const get17 = oc .route({ description: 'Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)', @@ -1181,13 +1205,13 @@ export const get16 = oc .output(zGetAppsByAppIdAgentDriveFilesPreviewResponse) export const preview4 = { - get: get16, + get: get17, } /** * List agent drive entries (read-only inspector; one endpoint for both tabs) */ -export const get17 = oc +export const get18 = oc .route({ description: 'List agent drive entries (read-only inspector; one endpoint for both tabs)', inputStructure: 'detailed', @@ -1205,7 +1229,7 @@ export const get17 = oc .output(zGetAppsByAppIdAgentDriveFilesResponse) export const files3 = { - get: get17, + get: get18, download: download4, preview: preview4, } @@ -1213,7 +1237,7 @@ export const files3 = { /** * Inspect one drive-backed skill for slash-menu hover/detail UI */ -export const get18 = oc +export const get19 = oc .route({ description: 'Inspect one drive-backed skill for slash-menu hover/detail UI', inputStructure: 'detailed', @@ -1231,7 +1255,7 @@ export const get18 = oc .output(zGetAppsByAppIdAgentDriveSkillsBySkillPathInspectResponse) export const inspect2 = { - get: get18, + get: get19, } export const bySkillPath = { @@ -1241,7 +1265,7 @@ export const bySkillPath = { /** * List drive-backed skills for the bound agent */ -export const get19 = oc +export const get20 = oc .route({ description: 'List drive-backed skills for the bound agent', inputStructure: 'detailed', @@ -1259,7 +1283,7 @@ export const get19 = oc .output(zGetAppsByAppIdAgentDriveSkillsResponse) export const skills2 = { - get: get19, + get: get20, bySkillPath, } @@ -1323,7 +1347,7 @@ export const files4 = { * * Get agent execution logs for an application */ -export const get20 = oc +export const get21 = oc .route({ description: 'Get agent execution logs for an application', inputStructure: 'detailed', @@ -1337,7 +1361,7 @@ export const get20 = oc .output(zGetAppsByAppIdAgentLogsResponse) export const logs = { - get: get20, + get: get21, } /** @@ -1439,7 +1463,7 @@ export const agent = { /** * Get status of annotation reply action job */ -export const get21 = oc +export const get22 = oc .route({ description: 'Get status of annotation reply action job', inputStructure: 'detailed', @@ -1452,7 +1476,7 @@ export const get21 = oc .output(zGetAppsByAppIdAnnotationReplyByActionStatusByJobIdResponse) export const byJobId = { - get: get21, + get: get22, } export const status = { @@ -1491,7 +1515,7 @@ export const annotationReply = { /** * Get annotation settings for an app */ -export const get22 = oc +export const get23 = oc .route({ description: 'Get annotation settings for an app', inputStructure: 'detailed', @@ -1504,7 +1528,7 @@ export const get22 = oc .output(zGetAppsByAppIdAnnotationSettingResponse) export const annotationSetting = { - get: get22, + get: get23, } /** @@ -1557,7 +1581,7 @@ export const batchImport = { /** * Get status of batch import job */ -export const get23 = oc +export const get24 = oc .route({ description: 'Get status of batch import job', inputStructure: 'detailed', @@ -1570,7 +1594,7 @@ export const get23 = oc .output(zGetAppsByAppIdAnnotationsBatchImportStatusByJobIdResponse) export const byJobId2 = { - get: get23, + get: get24, } export const batchImportStatus = { @@ -1580,7 +1604,7 @@ export const batchImportStatus = { /** * Get count of message annotations for the app */ -export const get24 = oc +export const get25 = oc .route({ description: 'Get count of message annotations for the app', inputStructure: 'detailed', @@ -1593,13 +1617,13 @@ export const get24 = oc .output(zGetAppsByAppIdAnnotationsCountResponse) export const count2 = { - get: get24, + get: get25, } /** * Export all annotations for an app with CSV injection protection */ -export const get25 = oc +export const get26 = oc .route({ description: 'Export all annotations for an app with CSV injection protection', inputStructure: 'detailed', @@ -1612,13 +1636,13 @@ export const get25 = oc .output(zGetAppsByAppIdAnnotationsExportResponse) export const export_ = { - get: get25, + get: get26, } /** * Get hit histories for an annotation */ -export const get26 = oc +export const get27 = oc .route({ description: 'Get hit histories for an annotation', inputStructure: 'detailed', @@ -1636,7 +1660,7 @@ export const get26 = oc .output(zGetAppsByAppIdAnnotationsByAnnotationIdHitHistoriesResponse) export const hitHistories = { - get: get26, + get: get27, } export const delete5 = oc @@ -1692,7 +1716,7 @@ export const delete6 = oc /** * Get annotations for an app with pagination */ -export const get27 = oc +export const get28 = oc .route({ description: 'Get annotations for an app with pagination', inputStructure: 'detailed', @@ -1729,7 +1753,7 @@ export const post18 = oc export const annotations = { delete: delete6, - get: get27, + get: get28, post: post18, batchImport, batchImportStatus, @@ -1797,7 +1821,7 @@ export const delete7 = oc /** * Get chat conversation details */ -export const get28 = oc +export const get29 = oc .route({ description: 'Get chat conversation details', inputStructure: 'detailed', @@ -1811,13 +1835,13 @@ export const get28 = oc export const byConversationId = { delete: delete7, - get: get28, + get: get29, } /** * Get chat conversations with pagination, filtering and summary */ -export const get29 = oc +export const get30 = oc .route({ description: 'Get chat conversations with pagination, filtering and summary', inputStructure: 'detailed', @@ -1835,14 +1859,14 @@ export const get29 = oc .output(zGetAppsByAppIdChatConversationsResponse) export const chatConversations = { - get: get29, + get: get30, byConversationId, } /** * Get suggested questions for a message */ -export const get30 = oc +export const get31 = oc .route({ description: 'Get suggested questions for a message', inputStructure: 'detailed', @@ -1855,7 +1879,7 @@ export const get30 = oc .output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse) export const suggestedQuestions = { - get: get30, + get: get31, } export const byMessageId = { @@ -1888,7 +1912,7 @@ export const byTaskId = { /** * Get chat messages for a conversation with pagination */ -export const get31 = oc +export const get32 = oc .route({ description: 'Get chat messages for a conversation with pagination', inputStructure: 'detailed', @@ -1903,7 +1927,7 @@ export const get31 = oc .output(zGetAppsByAppIdChatMessagesResponse) export const chatMessages = { - get: get31, + get: get32, byMessageId, byTaskId, } @@ -1927,7 +1951,7 @@ export const delete8 = oc /** * Get completion conversation details with messages */ -export const get32 = oc +export const get33 = oc .route({ description: 'Get completion conversation details with messages', inputStructure: 'detailed', @@ -1941,13 +1965,13 @@ export const get32 = oc export const byConversationId2 = { delete: delete8, - get: get32, + get: get33, } /** * Get completion conversations with pagination and filtering */ -export const get33 = oc +export const get34 = oc .route({ description: 'Get completion conversations with pagination and filtering', inputStructure: 'detailed', @@ -1965,7 +1989,7 @@ export const get33 = oc .output(zGetAppsByAppIdCompletionConversationsResponse) export const completionConversations = { - get: get33, + get: get34, byConversationId: byConversationId2, } @@ -2020,7 +2044,7 @@ export const completionMessages = { /** * Get conversation variables for an application */ -export const get34 = oc +export const get35 = oc .route({ description: 'Get conversation variables for an application', inputStructure: 'detailed', @@ -2038,7 +2062,7 @@ export const get34 = oc .output(zGetAppsByAppIdConversationVariablesResponse) export const conversationVariables = { - get: get34, + get: get35, } /** @@ -2099,7 +2123,7 @@ export const copy = { * * Export application configuration as DSL */ -export const get35 = oc +export const get36 = oc .route({ description: 'Export application configuration as DSL', inputStructure: 'detailed', @@ -2115,13 +2139,13 @@ export const get35 = oc .output(zGetAppsByAppIdExportResponse) export const export2 = { - get: get35, + get: get36, } /** * Export user feedback data for Google Sheets */ -export const get36 = oc +export const get37 = oc .route({ description: 'Export user feedback data for Google Sheets', inputStructure: 'detailed', @@ -2139,7 +2163,7 @@ export const get36 = oc .output(zGetAppsByAppIdFeedbacksExportResponse) export const export3 = { - get: get36, + get: get37, } /** @@ -2184,7 +2208,7 @@ export const icon = { /** * Get message details by ID */ -export const get37 = oc +export const get38 = oc .route({ description: 'Get message details by ID', inputStructure: 'detailed', @@ -2197,7 +2221,7 @@ export const get37 = oc .output(zGetAppsByAppIdMessagesByMessageIdResponse) export const byMessageId2 = { - get: get37, + get: get38, } export const messages = { @@ -2269,7 +2293,7 @@ export const publishToCreatorsPlatform = { /** * Get MCP server configuration for an application */ -export const get38 = oc +export const get39 = oc .route({ description: 'Get MCP server configuration for an application', inputStructure: 'detailed', @@ -2313,7 +2337,7 @@ export const put = oc .output(zPutAppsByAppIdServerResponse) export const server = { - get: get38, + get: get39, post: post31, put, } @@ -2414,7 +2438,7 @@ export const star = { /** * Get average response time statistics for an application */ -export const get39 = oc +export const get40 = oc .route({ description: 'Get average response time statistics for an application', inputStructure: 'detailed', @@ -2432,13 +2456,13 @@ export const get39 = oc .output(zGetAppsByAppIdStatisticsAverageResponseTimeResponse) export const averageResponseTime = { - get: get39, + get: get40, } /** * Get average session interaction statistics for an application */ -export const get40 = oc +export const get41 = oc .route({ description: 'Get average session interaction statistics for an application', inputStructure: 'detailed', @@ -2456,13 +2480,13 @@ export const get40 = oc .output(zGetAppsByAppIdStatisticsAverageSessionInteractionsResponse) export const averageSessionInteractions = { - get: get40, + get: get41, } /** * Get daily conversation statistics for an application */ -export const get41 = oc +export const get42 = oc .route({ description: 'Get daily conversation statistics for an application', inputStructure: 'detailed', @@ -2480,13 +2504,13 @@ export const get41 = oc .output(zGetAppsByAppIdStatisticsDailyConversationsResponse) export const dailyConversations = { - get: get41, + get: get42, } /** * Get daily terminal/end-user statistics for an application */ -export const get42 = oc +export const get43 = oc .route({ description: 'Get daily terminal/end-user statistics for an application', inputStructure: 'detailed', @@ -2504,13 +2528,13 @@ export const get42 = oc .output(zGetAppsByAppIdStatisticsDailyEndUsersResponse) export const dailyEndUsers = { - get: get42, + get: get43, } /** * Get daily message statistics for an application */ -export const get43 = oc +export const get44 = oc .route({ description: 'Get daily message statistics for an application', inputStructure: 'detailed', @@ -2528,13 +2552,13 @@ export const get43 = oc .output(zGetAppsByAppIdStatisticsDailyMessagesResponse) export const dailyMessages = { - get: get43, + get: get44, } /** * Get daily token cost statistics for an application */ -export const get44 = oc +export const get45 = oc .route({ description: 'Get daily token cost statistics for an application', inputStructure: 'detailed', @@ -2552,13 +2576,13 @@ export const get44 = oc .output(zGetAppsByAppIdStatisticsTokenCostsResponse) export const tokenCosts = { - get: get44, + get: get45, } /** * Get tokens per second statistics for an application */ -export const get45 = oc +export const get46 = oc .route({ description: 'Get tokens per second statistics for an application', inputStructure: 'detailed', @@ -2576,13 +2600,13 @@ export const get45 = oc .output(zGetAppsByAppIdStatisticsTokensPerSecondResponse) export const tokensPerSecond = { - get: get45, + get: get46, } /** * Get user satisfaction rate statistics for an application */ -export const get46 = oc +export const get47 = oc .route({ description: 'Get user satisfaction rate statistics for an application', inputStructure: 'detailed', @@ -2600,7 +2624,7 @@ export const get46 = oc .output(zGetAppsByAppIdStatisticsUserSatisfactionRateResponse) export const userSatisfactionRate = { - get: get46, + get: get47, } export const statistics = { @@ -2617,7 +2641,7 @@ export const statistics = { /** * Get available TTS voices for a specific language */ -export const get47 = oc +export const get48 = oc .route({ description: 'Get available TTS voices for a specific language', inputStructure: 'detailed', @@ -2635,7 +2659,7 @@ export const get47 = oc .output(zGetAppsByAppIdTextToAudioVoicesResponse) export const voices = { - get: get47, + get: get48, } /** @@ -2665,7 +2689,7 @@ export const textToAudio = { * * Get app tracing configuration */ -export const get48 = oc +export const get49 = oc .route({ description: 'Get app tracing configuration', inputStructure: 'detailed', @@ -2694,7 +2718,7 @@ export const post37 = oc .output(zPostAppsByAppIdTraceResponse) export const trace = { - get: get48, + get: get49, post: post37, } @@ -2725,7 +2749,7 @@ export const delete10 = oc /** * Get tracing configuration for an application */ -export const get49 = oc +export const get50 = oc .route({ description: 'Get tracing configuration for an application', inputStructure: 'detailed', @@ -2782,7 +2806,7 @@ export const post38 = oc export const traceConfig = { delete: delete10, - get: get49, + get: get50, patch, post: post38, } @@ -2814,7 +2838,7 @@ export const triggerEnable = { /** * Get app triggers list */ -export const get50 = oc +export const get51 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -2827,7 +2851,7 @@ export const get50 = oc .output(zGetAppsByAppIdTriggersResponse) export const triggers = { - get: get50, + get: get51, } /** @@ -2835,7 +2859,7 @@ export const triggers = { * * Get workflow application execution logs */ -export const get51 = oc +export const get52 = oc .route({ description: 'Get workflow application execution logs', inputStructure: 'detailed', @@ -2854,7 +2878,7 @@ export const get51 = oc .output(zGetAppsByAppIdWorkflowAppLogsResponse) export const workflowAppLogs = { - get: get51, + get: get52, } /** @@ -2862,7 +2886,7 @@ export const workflowAppLogs = { * * Get workflow archived execution logs */ -export const get52 = oc +export const get53 = oc .route({ description: 'Get workflow archived execution logs', inputStructure: 'detailed', @@ -2881,7 +2905,7 @@ export const get52 = oc .output(zGetAppsByAppIdWorkflowArchivedLogsResponse) export const workflowArchivedLogs = { - get: get52, + get: get53, } /** @@ -2889,7 +2913,7 @@ export const workflowArchivedLogs = { * * Get workflow runs count statistics */ -export const get53 = oc +export const get54 = oc .route({ description: 'Get workflow runs count statistics', inputStructure: 'detailed', @@ -2908,7 +2932,7 @@ export const get53 = oc .output(zGetAppsByAppIdWorkflowRunsCountResponse) export const count3 = { - get: get53, + get: get54, } /** @@ -2944,7 +2968,7 @@ export const tasks = { /** * Generate a download URL for an archived workflow run. */ -export const get54 = oc +export const get55 = oc .route({ description: 'Generate a download URL for an archived workflow run.', inputStructure: 'detailed', @@ -2957,7 +2981,7 @@ export const get54 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdExportResponse) export const export4 = { - get: get54, + get: get55, } /** @@ -2965,7 +2989,7 @@ export const export4 = { * * Get workflow run node execution list */ -export const get55 = oc +export const get56 = oc .route({ description: 'Get workflow run node execution list', inputStructure: 'detailed', @@ -2979,7 +3003,7 @@ export const get55 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdNodeExecutionsResponse) export const nodeExecutions = { - get: get55, + get: get56, } /** @@ -2987,7 +3011,7 @@ export const nodeExecutions = { * * Get workflow run detail */ -export const get56 = oc +export const get57 = oc .route({ description: 'Get workflow run detail', inputStructure: 'detailed', @@ -3001,7 +3025,7 @@ export const get56 = oc .output(zGetAppsByAppIdWorkflowRunsByRunIdResponse) export const byRunId = { - get: get56, + get: get57, export: export4, nodeExecutions, } @@ -3009,7 +3033,7 @@ export const byRunId = { /** * Read a text/binary preview file in a workflow Agent node sandbox */ -export const get57 = oc +export const get58 = oc .route({ description: 'Read a text/binary preview file in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -3027,7 +3051,7 @@ export const get57 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse) export const read = { - get: get57, + get: get58, } /** @@ -3057,7 +3081,7 @@ export const upload3 = { /** * List a directory in a workflow Agent node sandbox */ -export const get58 = oc +export const get59 = oc .route({ description: 'List a directory in a workflow Agent node sandbox', inputStructure: 'detailed', @@ -3076,7 +3100,7 @@ export const get58 = oc .output(zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse) export const files5 = { - get: get58, + get: get59, read, upload: upload3, } @@ -3102,7 +3126,7 @@ export const byWorkflowRunId = { * * Get workflow run list */ -export const get59 = oc +export const get60 = oc .route({ description: 'Get workflow run list', inputStructure: 'detailed', @@ -3121,7 +3145,7 @@ export const get59 = oc .output(zGetAppsByAppIdWorkflowRunsResponse) export const workflowRuns2 = { - get: get59, + get: get60, count: count3, tasks, byRunId, @@ -3133,7 +3157,7 @@ export const workflowRuns2 = { * * Get all users in current tenant for mentions */ -export const get60 = oc +export const get61 = oc .route({ description: 'Get all users in current tenant for mentions', inputStructure: 'detailed', @@ -3147,7 +3171,7 @@ export const get60 = oc .output(zGetAppsByAppIdWorkflowCommentsMentionUsersResponse) export const mentionUsers = { - get: get60, + get: get61, } /** @@ -3272,7 +3296,7 @@ export const delete12 = oc * * Get a specific workflow comment */ -export const get61 = oc +export const get62 = oc .route({ description: 'Get a specific workflow comment', inputStructure: 'detailed', @@ -3310,7 +3334,7 @@ export const put3 = oc export const byCommentId = { delete: delete12, - get: get61, + get: get62, put: put3, replies, resolve, @@ -3321,7 +3345,7 @@ export const byCommentId = { * * Get all comments for a workflow */ -export const get62 = oc +export const get63 = oc .route({ description: 'Get all comments for a workflow', inputStructure: 'detailed', @@ -3359,7 +3383,7 @@ export const post44 = oc .output(zPostAppsByAppIdWorkflowCommentsResponse) export const comments = { - get: get62, + get: get63, post: post44, mentionUsers, byCommentId, @@ -3368,7 +3392,7 @@ export const comments = { /** * Get workflow average app interaction statistics */ -export const get63 = oc +export const get64 = oc .route({ description: 'Get workflow average app interaction statistics', inputStructure: 'detailed', @@ -3386,13 +3410,13 @@ export const get63 = oc .output(zGetAppsByAppIdWorkflowStatisticsAverageAppInteractionsResponse) export const averageAppInteractions = { - get: get63, + get: get64, } /** * Get workflow daily runs statistics */ -export const get64 = oc +export const get65 = oc .route({ description: 'Get workflow daily runs statistics', inputStructure: 'detailed', @@ -3410,13 +3434,13 @@ export const get64 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyConversationsResponse) export const dailyConversations2 = { - get: get64, + get: get65, } /** * Get workflow daily terminals statistics */ -export const get65 = oc +export const get66 = oc .route({ description: 'Get workflow daily terminals statistics', inputStructure: 'detailed', @@ -3434,13 +3458,13 @@ export const get65 = oc .output(zGetAppsByAppIdWorkflowStatisticsDailyTerminalsResponse) export const dailyTerminals = { - get: get65, + get: get66, } /** * Get workflow daily token cost statistics */ -export const get66 = oc +export const get67 = oc .route({ description: 'Get workflow daily token cost statistics', inputStructure: 'detailed', @@ -3458,7 +3482,7 @@ export const get66 = oc .output(zGetAppsByAppIdWorkflowStatisticsTokenCostsResponse) export const tokenCosts2 = { - get: get66, + get: get67, } export const statistics2 = { @@ -3478,7 +3502,7 @@ export const workflow = { * * Get default block configuration by type */ -export const get67 = oc +export const get68 = oc .route({ description: 'Get default block configuration by type', inputStructure: 'detailed', @@ -3497,7 +3521,7 @@ export const get67 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsByBlockTypeResponse) export const byBlockType = { - get: get67, + get: get68, } /** @@ -3505,7 +3529,7 @@ export const byBlockType = { * * Get default block configurations for workflow */ -export const get68 = oc +export const get69 = oc .route({ description: 'Get default block configurations for workflow', inputStructure: 'detailed', @@ -3519,14 +3543,14 @@ export const get68 = oc .output(zGetAppsByAppIdWorkflowsDefaultWorkflowBlockConfigsResponse) export const defaultWorkflowBlockConfigs = { - get: get68, + get: get69, byBlockType, } /** * Get conversation variables for workflow */ -export const get69 = oc +export const get70 = oc .route({ description: 'Get conversation variables for workflow', inputStructure: 'detailed', @@ -3559,7 +3583,7 @@ export const post45 = oc .output(zPostAppsByAppIdWorkflowsDraftConversationVariablesResponse) export const conversationVariables2 = { - get: get69, + get: get70, post: post45, } @@ -3568,7 +3592,7 @@ export const conversationVariables2 = { * * Get environment variables for workflow */ -export const get70 = oc +export const get71 = oc .route({ description: 'Get environment variables for workflow', inputStructure: 'detailed', @@ -3602,7 +3626,7 @@ export const post46 = oc .output(zPostAppsByAppIdWorkflowsDraftEnvironmentVariablesResponse) export const environmentVariables = { - get: get70, + get: get71, post: post46, } @@ -3807,7 +3831,7 @@ export const loop2 = { nodes: nodes6, } -export const get71 = oc +export const get72 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3821,7 +3845,7 @@ export const get71 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse) export const candidates = { - get: get71, + get: get72, } export const post53 = oc @@ -3904,7 +3928,7 @@ export const validate = { post: post56, } -export const get72 = oc +export const get73 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -3937,7 +3961,7 @@ export const put4 = oc .output(zPutAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerResponse) export const agentComposer = { - get: get72, + get: get73, put: put4, candidates, copyFromRoster, @@ -3949,7 +3973,7 @@ export const agentComposer = { /** * Get last run result for draft workflow node */ -export const get73 = oc +export const get74 = oc .route({ description: 'Get last run result for draft workflow node', inputStructure: 'detailed', @@ -3962,7 +3986,7 @@ export const get73 = oc .output(zGetAppsByAppIdWorkflowsDraftNodesByNodeIdLastRunResponse) export const lastRun = { - get: get73, + get: get74, } /** @@ -4037,7 +4061,7 @@ export const delete13 = oc /** * Get variables for a specific node */ -export const get74 = oc +export const get75 = oc .route({ description: 'Get variables for a specific node', inputStructure: 'detailed', @@ -4051,7 +4075,7 @@ export const get74 = oc export const variables = { delete: delete13, - get: get74, + get: get75, } export const byNodeId8 = { @@ -4096,7 +4120,7 @@ export const run10 = { /** * Server-Sent Events stream of inspector deltas for a draft workflow run. */ -export const get75 = oc +export const get76 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a draft workflow run.', inputStructure: 'detailed', @@ -4109,13 +4133,13 @@ export const get75 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsEventsResponse) export const events = { - get: get75, + get: get76, } /** * Full value for one declared output, including signed download URL for files. */ -export const get76 = oc +export const get77 = oc .route({ description: 'Full value for one declared output, including signed download URL for files.', inputStructure: 'detailed', @@ -4132,7 +4156,7 @@ export const get76 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdByOutputNamePreviewResponse) export const preview6 = { - get: get76, + get: get77, } export const byOutputName = { @@ -4142,7 +4166,7 @@ export const byOutputName = { /** * One node's declared outputs for a draft workflow run. */ -export const get77 = oc +export const get78 = oc .route({ description: "One node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -4155,14 +4179,14 @@ export const get77 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId9 = { - get: get77, + get: get78, byOutputName, } /** * Snapshot of every node's declared outputs for a draft workflow run. */ -export const get78 = oc +export const get79 = oc .route({ description: "Snapshot of every node's declared outputs for a draft workflow run.", inputStructure: 'detailed', @@ -4175,7 +4199,7 @@ export const get78 = oc .output(zGetAppsByAppIdWorkflowsDraftRunsByRunIdNodeOutputsResponse) export const nodeOutputs = { - get: get78, + get: get79, events, byNodeId: byNodeId9, } @@ -4191,7 +4215,7 @@ export const runs = { /** * Get system variables for workflow */ -export const get79 = oc +export const get80 = oc .route({ description: 'Get system variables for workflow', inputStructure: 'detailed', @@ -4204,7 +4228,7 @@ export const get79 = oc .output(zGetAppsByAppIdWorkflowsDraftSystemVariablesResponse) export const systemVariables = { - get: get79, + get: get80, } /** @@ -4304,7 +4328,7 @@ export const delete14 = oc /** * Get a specific workflow variable */ -export const get80 = oc +export const get81 = oc .route({ description: 'Get a specific workflow variable', inputStructure: 'detailed', @@ -4338,7 +4362,7 @@ export const patch2 = oc export const byVariableId = { delete: delete14, - get: get80, + get: get81, patch: patch2, reset, } @@ -4364,7 +4388,7 @@ export const delete15 = oc * * Get draft workflow variables */ -export const get81 = oc +export const get82 = oc .route({ description: 'Get draft workflow variables', inputStructure: 'detailed', @@ -4384,7 +4408,7 @@ export const get81 = oc export const variables2 = { delete: delete15, - get: get81, + get: get82, byVariableId, } @@ -4393,7 +4417,7 @@ export const variables2 = { * * Get draft workflow for an application */ -export const get82 = oc +export const get83 = oc .route({ description: 'Get draft workflow for an application', inputStructure: 'detailed', @@ -4430,7 +4454,7 @@ export const post62 = oc .output(zPostAppsByAppIdWorkflowsDraftResponse) export const draft2 = { - get: get82, + get: get83, post: post62, conversationVariables: conversationVariables2, environmentVariables, @@ -4451,7 +4475,7 @@ export const draft2 = { * * Get published workflow for an application */ -export const get83 = oc +export const get84 = oc .route({ description: 'Get published workflow for an application', inputStructure: 'detailed', @@ -4485,14 +4509,14 @@ export const post63 = oc .output(zPostAppsByAppIdWorkflowsPublishResponse) export const publish = { - get: get83, + get: get84, post: post63, } /** * Server-Sent Events stream of inspector deltas for a published workflow run. */ -export const get84 = oc +export const get85 = oc .route({ description: 'Server-Sent Events stream of inspector deltas for a published workflow run.', inputStructure: 'detailed', @@ -4505,13 +4529,13 @@ export const get84 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsEventsResponse) export const events2 = { - get: get84, + get: get85, } /** * Full value for one declared output of a published run. */ -export const get85 = oc +export const get86 = oc .route({ description: 'Full value for one declared output of a published run.', inputStructure: 'detailed', @@ -4532,7 +4556,7 @@ export const get85 = oc ) export const preview7 = { - get: get85, + get: get86, } export const byOutputName2 = { @@ -4542,7 +4566,7 @@ export const byOutputName2 = { /** * One node's declared outputs for a published workflow run. */ -export const get86 = oc +export const get87 = oc .route({ description: "One node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4555,14 +4579,14 @@ export const get86 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsByNodeIdResponse) export const byNodeId10 = { - get: get86, + get: get87, byOutputName: byOutputName2, } /** * Snapshot of every node's declared outputs for a published workflow run. */ -export const get87 = oc +export const get88 = oc .route({ description: "Snapshot of every node's declared outputs for a published workflow run.", inputStructure: 'detailed', @@ -4575,7 +4599,7 @@ export const get87 = oc .output(zGetAppsByAppIdWorkflowsPublishedRunsByRunIdNodeOutputsResponse) export const nodeOutputs2 = { - get: get87, + get: get88, events: events2, byNodeId: byNodeId10, } @@ -4595,7 +4619,7 @@ export const published = { /** * Get webhook trigger for a node */ -export const get88 = oc +export const get89 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -4613,7 +4637,7 @@ export const get88 = oc .output(zGetAppsByAppIdWorkflowsTriggersWebhookResponse) export const webhook = { - get: get88, + get: get89, } export const triggers2 = { @@ -4689,7 +4713,7 @@ export const byWorkflowId = { * * Get all published workflows for an application */ -export const get89 = oc +export const get90 = oc .route({ description: 'Get all published workflows for an application', inputStructure: 'detailed', @@ -4708,7 +4732,7 @@ export const get89 = oc .output(zGetAppsByAppIdWorkflowsResponse) export const workflows3 = { - get: get89, + get: get90, defaultWorkflowBlockConfigs, draft: draft2, publish, @@ -4741,7 +4765,7 @@ export const delete17 = oc * * Get application details */ -export const get90 = oc +export const get91 = oc .route({ description: 'Get application details', inputStructure: 'detailed', @@ -4774,7 +4798,7 @@ export const put6 = oc export const byAppId2 = { delete: delete17, - get: get90, + get: get91, put: put6, advancedChat, agent, @@ -4843,7 +4867,7 @@ export const byApiKeyId = { * * Get all API keys for an app */ -export const get91 = oc +export const get92 = oc .route({ description: 'Get all API keys for an app', inputStructure: 'detailed', @@ -4876,7 +4900,7 @@ export const post65 = oc .output(zPostAppsByResourceIdApiKeysResponse) export const apiKeys = { - get: get91, + get: get92, post: post65, byApiKeyId, } @@ -4888,7 +4912,7 @@ export const byResourceId = { /** * Refresh MCP server configuration and regenerate server code */ -export const get92 = oc +export const get93 = oc .route({ description: 'Refresh MCP server configuration and regenerate server code', inputStructure: 'detailed', @@ -4901,7 +4925,7 @@ export const get92 = oc .output(zGetAppsByServerIdServerRefreshResponse) export const refresh = { - get: get92, + get: get93, } export const server2 = { @@ -4917,7 +4941,7 @@ export const byServerId = { * * Get list of applications with pagination and filtering */ -export const get93 = oc +export const get94 = oc .route({ description: 'Get list of applications with pagination and filtering', inputStructure: 'detailed', @@ -4950,9 +4974,10 @@ export const post66 = oc .output(zPostAppsResponse) export const apps = { - get: get93, + get: get94, post: post66, imports, + recent, starred, workflows, byAppId: byAppId2, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index abf5b16538a..c8bd2dfe64e 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -80,6 +80,10 @@ export type CheckDependenciesResult = { leaked_dependencies?: Array } +export type RecentAppListResponse = { + data: Array +} + export type WorkflowOnlineUsersPayload = { app_ids?: Array } @@ -1408,6 +1412,20 @@ export type PluginDependency = { value: Github | Marketplace | Package } +export type RecentAppResponse = { + author_name?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + readonly icon_url: string | null + id: string + maintainer?: string | null + mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' + name: string + permission_keys?: Array + updated_at: number +} + export type WorkflowOnlineUsersByApp = { app_id: string users: Array @@ -3114,6 +3132,10 @@ export type AppDetailWithSiteWritable = { workflow?: WorkflowPartial | null } +export type RecentAppListResponseWritable = { + data: Array +} + export type GeneratedAppResponseWritable = JsonValue export type WorkflowCommentBasicListWritable = { @@ -3196,6 +3218,19 @@ export type AppDetailSiteResponseWritable = { use_icon_as_answer_icon?: boolean | null } +export type RecentAppResponseWritable = { + author_name?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: IconType | null + id: string + maintainer?: string | null + mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow' + name: string + permission_keys?: Array + updated_at: number +} + export type WorkflowCommentBasicWritable = { content: string created_at?: number | null @@ -3356,6 +3391,21 @@ export type PostAppsImportsByImportIdConfirmResponses = { export type PostAppsImportsByImportIdConfirmResponse = PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses] +export type GetAppsRecentData = { + body?: never + path?: never + query?: { + limit?: number + } + url: '/apps/recent' +} + +export type GetAppsRecentResponses = { + 200: RecentAppListResponse +} + +export type GetAppsRecentResponse = GetAppsRecentResponses[keyof GetAppsRecentResponses] + export type GetAppsStarredData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 88dcfc13682..534a11ff431 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1076,6 +1076,30 @@ export const zAppImportResponse = z.object({ warnings: z.array(zDslImportWarning).optional(), }) +/** + * RecentAppResponse + */ +export const zRecentAppResponse = z.object({ + author_name: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + icon_url: z.string().nullable(), + id: z.string(), + maintainer: z.string().nullish(), + mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + updated_at: z.int(), +}) + +/** + * RecentAppListResponse + */ +export const zRecentAppListResponse = z.object({ + data: z.array(zRecentAppResponse), +}) + export const zJsonValue = z .union([ z.string(), @@ -4279,6 +4303,29 @@ export const zAppDetailWithSiteWritable = z.object({ workflow: zWorkflowPartial.nullish(), }) +/** + * RecentAppResponse + */ +export const zRecentAppResponseWritable = z.object({ + author_name: z.string().nullish(), + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: zIconType.nullish(), + id: z.string(), + maintainer: z.string().nullish(), + mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']), + name: z.string(), + permission_keys: z.array(z.string()).optional(), + updated_at: z.int(), +}) + +/** + * RecentAppListResponse + */ +export const zRecentAppListResponseWritable = z.object({ + data: z.array(zRecentAppResponseWritable), +}) + /** * AccountWithRoleResponse */ @@ -4442,6 +4489,15 @@ export const zPostAppsImportsByImportIdConfirmPath = z.object({ */ export const zPostAppsImportsByImportIdConfirmResponse = zImport +export const zGetAppsRecentQuery = z.object({ + limit: z.int().gte(1).lte(8).optional().default(8), +}) + +/** + * Success + */ +export const zGetAppsRecentResponse = zRecentAppListResponse + export const zGetAppsStarredQuery = z.object({ creator_ids: z.array(z.string()).optional(), is_created_by_me: z.boolean().optional(), diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index aa034c60408..8b61e29cfbc 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' import type { StepByStepTourStatePatchPayload, StepByStepTourStateResponse, @@ -9,7 +10,6 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' import type { Banner as BannerType } from '@/models/app' import type { App } from '@/models/explore' -import type { App as WorkspaceApp } from '@/types/app' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider, useSetAtom } from 'jotai' @@ -57,7 +57,7 @@ let mockExploreData: { categories: string[]; allList: App[] } | undefined = { } let mockLearnDifyApps: App[] = [] let mockLearnDifyLoading = false -let mockWorkspaceApps: WorkspaceApp[] = [] +let mockWorkspaceApps: RecentAppResponse[] = [] let mockWorkspaceAppsLoading = false let mockBanners: BannerType[] = [] let mockBannersLoading = false @@ -67,6 +67,10 @@ const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() const mockTrackCreateApp = vi.fn() const mockTrackEvent = vi.hoisted(() => vi.fn()) +const mockAppQueries = vi.hoisted(() => ({ + listQueryOptions: vi.fn(), + recentQueryOptions: vi.fn(), +})) const mockStepByStepTour = vi.hoisted(() => { const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const const createState = ( @@ -242,13 +246,14 @@ vi.mock('@/service/client', () => ({ queryOptions: (options: { input?: { query?: { limit?: number } } select?: (response: { - data: WorkspaceApp[] + data: RecentAppResponse[] has_more: boolean limit: number page: number total: number }) => unknown }) => { + mockAppQueries.listQueryOptions(options) const limit = options.input?.query?.limit ?? mockWorkspaceApps.length if (mockWorkspaceAppsLoading) { return { @@ -272,6 +277,33 @@ vi.mock('@/service/client', () => ({ } }, }, + recent: { + get: { + queryOptions: (options: { + input?: { query?: { limit?: number } } + select?: (response: { data: RecentAppResponse[] }) => unknown + }) => { + mockAppQueries.recentQueryOptions(options) + const limit = options.input?.query?.limit ?? mockWorkspaceApps.length + if (mockWorkspaceAppsLoading) { + return { + queryKey: ['console', 'apps', 'recent', 'get', options], + queryFn: () => new Promise(() => {}), + select: options.select, + } + } + const response = { + data: mockWorkspaceApps.slice(0, limit), + } + return { + queryKey: ['console', 'apps', 'recent', 'get', options], + queryFn: () => Promise.resolve(response), + initialData: response, + select: options.select, + } + }, + }, + }, }, onboarding: { stepByStepTour: { @@ -455,33 +487,19 @@ const createApp = (overrides: Partial = {}): App => ({ is_agent: overrides.is_agent ?? false, }) -const createWorkspaceApp = (overrides: Partial = {}): WorkspaceApp => - ({ - id: overrides.id ?? 'workspace-app-1', - name: overrides.name ?? 'Workspace App', - description: overrides.description ?? 'Workspace app description', - author_name: overrides.author_name ?? 'Evan', - icon_type: overrides.icon_type ?? 'emoji', - icon: overrides.icon ?? '😀', - icon_background: overrides.icon_background ?? '#fff', - icon_url: overrides.icon_url ?? null, - use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false, - mode: overrides.mode ?? AppModeEnum.CHAT, - created_at: overrides.created_at ?? 1704067200, - updated_at: overrides.updated_at ?? 1704153600, - enable_site: overrides.enable_site ?? false, - enable_api: overrides.enable_api ?? false, - api_rpm: overrides.api_rpm ?? 60, - api_rph: overrides.api_rph ?? 3600, - is_demo: overrides.is_demo ?? false, - model_config: overrides.model_config, - app_model_config: overrides.app_model_config, - site: overrides.site, - api_base_url: overrides.api_base_url ?? '', - tags: overrides.tags ?? [], - access_mode: overrides.access_mode, - permission_keys: overrides.permission_keys, - }) as WorkspaceApp +const createWorkspaceApp = (overrides: Partial = {}): RecentAppResponse => ({ + id: overrides.id ?? 'workspace-app-1', + name: overrides.name ?? 'Workspace App', + author_name: overrides.author_name ?? 'Evan', + icon_type: overrides.icon_type ?? 'emoji', + icon: overrides.icon ?? '😀', + icon_background: overrides.icon_background ?? '#fff', + icon_url: overrides.icon_url ?? null, + mode: overrides.mode ?? 'chat', + updated_at: overrides.updated_at ?? 1704153600, + maintainer: overrides.maintainer ?? 'user-1', + permission_keys: overrides.permission_keys, +}) const createBanner = (overrides: Partial = {}): BannerType => ({ id: overrides.id ?? 'banner-1', @@ -748,6 +766,27 @@ describe('AppList', () => { ).toHaveAttribute('href', '/apps') }) + it('should load continue work from the lightweight recent apps query', () => { + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockWorkspaceApps = [createWorkspaceApp()] + + renderAppList() + + expect(mockAppQueries.recentQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + query: { + limit: 8, + }, + }, + }), + ) + expect(mockAppQueries.listQueryOptions).not.toHaveBeenCalled() + }) + it('should render preview-only continue work app as a dimmed card and warn on click', () => { mockExploreData = { categories: ['Writing'], diff --git a/web/app/components/explore/app-list/explore-recommendations.tsx b/web/app/components/explore/app-list/explore-recommendations.tsx index 8f8c9f91216..2b653007e3b 100644 --- a/web/app/components/explore/app-list/explore-recommendations.tsx +++ b/web/app/components/explore/app-list/explore-recommendations.tsx @@ -1,7 +1,7 @@ 'use client' +import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' import type { App } from '@/models/explore' -import type { App as WorkspaceApp } from '@/types/app' import type { TryAppSelection } from '@/types/try-app' import ContinueWork from '@/app/components/explore/continue-work' import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' @@ -17,7 +17,7 @@ export function ExploreRecommendations({ onTry, }: { canCreate: boolean - continueWorkApps: WorkspaceApp[] + continueWorkApps: RecentAppResponse[] forceShowLearnDify?: boolean onCreate: (app: App) => void onTry: (params: TryAppSelection) => void diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index ed564cef852..62259226f30 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -1,10 +1,10 @@ 'use client' +import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { StepByStepTourTaskId } from '@/app/components/step-by-step-tour/types' import type { Banner as BannerType } from '@/models/app' import type { App } from '@/models/explore' -import type { App as WorkspaceApp } from '@/types/app' import type { TryAppSelection } from '@/types/try-app' import type { TrackCreateAppParams } from '@/utils/create-app-tracking' import { cn } from '@langgenius/dify-ui/cn' @@ -39,7 +39,6 @@ import { DSLImportMode } from '@/models/app' import dynamic from '@/next/dynamic' import { consoleQuery } from '@/service/client' import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' -import { normalizeAppPagination } from '@/service/use-apps' import { trackCreateApp } from '@/utils/create-app-tracking' import { hasPermission } from '@/utils/permission' import { ExploreAppListHeader } from './explore-app-list-header' @@ -61,9 +60,7 @@ type ExploreAppListData = { const homeContinueWorkAppsInput = { query: { - page: 1, limit: 8, - name: '', }, } @@ -91,9 +88,9 @@ function getExploreAppListQueryOptions(locale?: string) { } function getContinueWorkAppsQueryOptions() { - return consoleQuery.apps.get.queryOptions({ + return consoleQuery.apps.recent.get.queryOptions({ input: homeContinueWorkAppsInput, - select: (response): WorkspaceApp[] => normalizeAppPagination(response).data, + select: (response): RecentAppResponse[] => response.data, }) } diff --git a/web/app/components/explore/continue-work/__tests__/item.spec.tsx b/web/app/components/explore/continue-work/__tests__/item.spec.tsx index be97f56a1f3..053691f6025 100644 --- a/web/app/components/explore/continue-work/__tests__/item.spec.tsx +++ b/web/app/components/explore/continue-work/__tests__/item.spec.tsx @@ -1,9 +1,7 @@ +import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' import type { AnchorHTMLAttributes, ReactNode } from 'react' -import type { App } from '@/types/app' import { fireEvent, screen } from '@testing-library/react' -import { AccessMode } from '@/models/access-control' import { renderWithConsoleQuery } from '@/test/console/query-data' -import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import ContinueWorkItem from '../item' @@ -52,37 +50,23 @@ vi.mock('@/next/link', () => ({ ), })) -const createApp = (overrides: Partial = {}): App => ({ +const createApp = (overrides: Partial = {}): RecentAppResponse => ({ id: 'app-1', name: 'Continue App', - description: 'Continue app description', author_name: 'Alice', icon_type: 'emoji', icon: '🤖', icon_background: '#FFEAD5', icon_url: null, - use_icon_as_answer_icon: false, - mode: AppModeEnum.CHAT, - enable_site: false, - enable_api: false, - api_rpm: 60, - api_rph: 3600, - is_demo: false, - model_config: {} as App['model_config'], - app_model_config: {} as App['app_model_config'], - created_at: 100, + mode: 'chat', maintainer: 'maintainer-1', updated_at: 200, - site: {} as App['site'], - api_base_url: '', - tags: [], - access_mode: AccessMode.PUBLIC, permission_keys: [AppACLPermission.Edit], ...overrides, }) const renderItem = ( - app: App, + app: RecentAppResponse, systemFeatures: NonNullable[1]>['systemFeatures'] = { rbac_enabled: true, }, @@ -109,12 +93,6 @@ describe('ContinueWorkItem', () => { expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000) }) - it('should use created time when updated time is missing', () => { - renderItem(createApp({ updated_at: 0, created_at: 123 })) - - expect(mockFormatTimeFromNow).toHaveBeenCalledWith(123000) - }) - it('should link to access config when RBAC is enabled and only access config permission is available', () => { renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] })) diff --git a/web/app/components/explore/continue-work/index.tsx b/web/app/components/explore/continue-work/index.tsx index 8f13cb04028..13a51d8211f 100644 --- a/web/app/components/explore/continue-work/index.tsx +++ b/web/app/components/explore/continue-work/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { App as WorkspaceApp } from '@/types/app' +import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -8,7 +8,7 @@ import Link from '@/next/link' import ContinueWorkItem from './item' type ContinueWorkProps = { - apps: WorkspaceApp[] + apps: RecentAppResponse[] className?: string } diff --git a/web/app/components/explore/continue-work/item.tsx b/web/app/components/explore/continue-work/item.tsx index 0dd8629e6e8..09f217c0309 100644 --- a/web/app/components/explore/continue-work/item.tsx +++ b/web/app/components/explore/continue-work/item.tsx @@ -1,6 +1,6 @@ 'use client' -import type { App } from '@/types/app' +import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' @@ -18,7 +18,7 @@ import { getRedirectionPath } from '@/utils/app-redirection' import { hasOnlyAppPreviewPermission } from '@/utils/permission' type ContinueWorkItemProps = { - app: App + app: RecentAppResponse } const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => { @@ -28,7 +28,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => { const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isRbacEnabled = systemFeatures.rbac_enabled - const updatedAt = (app.updated_at || app.created_at) * 1000 + const updatedAt = app.updated_at * 1000 const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) const href = getRedirectionPath(app, { currentUserId, @@ -58,7 +58,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => { From a752f43b8eea4cec575db7481d4b76da1155e9a2 Mon Sep 17 00:00:00 2001 From: Escape0707 Date: Mon, 27 Jul 2026 17:07:05 +0900 Subject: [PATCH 035/531] test: use pristine file-backed SQLite fixtures (#39624) Co-authored-by: Asuka Minato --- api/models/model.py | 4 +- api/tests/unit_tests/conftest.py | 82 ++++++++++++------ ...st_update_provider_when_message_created.py | 1 - .../services/test_account_service.py | 36 -------- api/tests/unit_tests/test_sqlite_fixtures.py | 83 +++++++++++++++++++ 5 files changed, 141 insertions(+), 65 deletions(-) create mode 100644 api/tests/unit_tests/test_sqlite_fixtures.py diff --git a/api/models/model.py b/api/models/model.py index bcefb1c22fd..07ac06284cb 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -1117,14 +1117,14 @@ class ExporleBanner(TypeBase): status: Mapped[BannerStatus] = mapped_column( EnumText(BannerStatus, length=255), nullable=False, - server_default=sa.text("'enabled'::character varying"), + server_default=sa.text("'enabled'"), default=BannerStatus.ENABLED, ) created_at: Mapped[datetime] = mapped_column( sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) language: Mapped[str] = mapped_column( - String(255), nullable=False, server_default=sa.text("'en-US'::character varying"), default="en-US" + String(255), nullable=False, server_default=sa.text("'en-US'"), default="en-US" ) diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index 0714ef1bd89..e0e4361d8cd 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -1,11 +1,13 @@ import os +import shutil from collections.abc import Iterator +from pathlib import Path from unittest.mock import MagicMock, patch import pytest from flask import Flask from sqlalchemy import create_engine -from sqlalchemy.engine import Engine +from sqlalchemy.engine import URL, Engine from sqlalchemy.orm import Session, sessionmaker # Getting the absolute path of the current file's directory @@ -35,7 +37,7 @@ os.environ.setdefault("OPENDAL_SCHEME", "fs") os.environ.setdefault("OPENDAL_FS_ROOT", "/tmp/dify-storage") os.environ.setdefault("STORAGE_TYPE", "opendal") -from core.db.session_factory import configure_session_factory, session_factory +import core.db.session_factory as session_factory_module from extensions import ext_redis from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.base import TypeBase @@ -111,42 +113,70 @@ def reset_secret_key(): dify_config.SECRET_KEY = original -@pytest.fixture(scope="session") -def _unit_test_engine(): - engine = create_engine("sqlite:///:memory:") - yield engine - engine.dispose() - - @pytest.fixture -def sqlite_engine() -> Iterator[Engine]: - """Create an isolated in-memory SQLite engine for tests that need a disposable database.""" +def _sqlite_engine(_sqlite_database_template: Path, tmp_path: Path) -> Iterator[Engine]: + """Create an engine over a pristine per-test copy of the SQLite schema.""" + + database_path = tmp_path / "unit-tests.sqlite3" + shutil.copyfile(_sqlite_database_template, database_path) + engine = create_engine(URL.create("sqlite", database=str(database_path))) - engine = create_engine("sqlite:///:memory:") try: yield engine finally: engine.dispose() + database_path.unlink(missing_ok=True) -@pytest.fixture -def sqlite_session(request: pytest.FixtureRequest, sqlite_engine: Engine) -> Iterator[Session]: - """Yield a SQLite session after creating the model tables passed through ``request.param``.""" +@pytest.fixture(scope="session") +def _sqlite_database_template(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Create one empty full-schema SQLite database per pytest worker.""" - models: tuple[type[TypeBase], ...] = request.param - tables = [model.metadata.tables[model.__tablename__] for model in models] - TypeBase.metadata.create_all(sqlite_engine, tables=tables) - session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - with session_factory() as session: - yield session + database_path = tmp_path_factory.mktemp("sqlite-template") / "unit-tests.sqlite3" + engine = create_engine(URL.create("sqlite", database=str(database_path))) + try: + TypeBase.metadata.create_all(engine) + finally: + engine.dispose() + return database_path @pytest.fixture(autouse=True) -def _configure_session_factory(_unit_test_engine): - try: - session_factory.get_session_maker() - except RuntimeError: - configure_session_factory(_unit_test_engine, expire_on_commit=False) +def _sqlite_session_factory( + _sqlite_engine: Engine, + monkeypatch: pytest.MonkeyPatch, +) -> sessionmaker[Session]: + """Bind all unit-test Sessions to the pristine full-schema SQLite database.""" + + factory = sessionmaker(bind=_sqlite_engine, expire_on_commit=False) + monkeypatch.setattr(session_factory_module, "_session_maker", factory) + return factory + + +@pytest.fixture +def sqlite_engine(_sqlite_engine: Engine) -> Engine: + """Expose the pristine full-schema SQLite engine to tests.""" + + return _sqlite_engine + + +@pytest.fixture +def sqlite_session_factory(_sqlite_session_factory: sessionmaker[Session]) -> sessionmaker[Session]: + """Expose the shared SQLite session factory to tests.""" + + return _sqlite_session_factory + + +@pytest.fixture +def sqlite_session(_sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]: + """Yield a session over the pristine full-schema SQLite database. + + Legacy indirect model parameters remain accepted by pytest but are ignored. + Remove those decorators as their test files receive individual review. + """ + + with _sqlite_session_factory() as session: + yield session def persist_service_api_tenant_owner(session: Session, tenant: Tenant, owner: Account) -> TenantAccountJoin: diff --git a/api/tests/unit_tests/events/test_update_provider_when_message_created.py b/api/tests/unit_tests/events/test_update_provider_when_message_created.py index a31f0ecdb97..54ae6460fb6 100644 --- a/api/tests/unit_tests/events/test_update_provider_when_message_created.py +++ b/api/tests/unit_tests/events/test_update_provider_when_message_created.py @@ -18,7 +18,6 @@ from models.provider import ProviderType @pytest.fixture def credit_pool_session_factory(sqlite_engine: Engine) -> Iterator[sessionmaker[Session]]: """Bind message-created accounting to fixture-owned SQLite sessions.""" - TenantCreditPool.__table__.create(sqlite_engine) session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) with patch("events.event_handlers.update_provider_when_message_created.db.session", session_factory): yield session_factory diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index e7288909a16..0357ab4b545 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -1,5 +1,4 @@ import json -from collections.abc import Iterator from datetime import datetime, timedelta from unittest.mock import MagicMock, patch from uuid import UUID @@ -11,7 +10,6 @@ from sqlalchemy.orm import Session from configs import dify_config from models.account import ( Account, - AccountIntegrate, AccountStatus, Tenant, TenantAccountJoin, @@ -114,22 +112,6 @@ class TestAccountService: - Error conditions and edge cases """ - @pytest.fixture - def sqlite_session(self, sqlite_engine) -> Iterator[Session]: - """SQLite session with the account/workspace tables these service tests touch.""" - tables = [ - model.metadata.tables[model.__tablename__] - for model in ( - Account, - Tenant, - TenantAccountJoin, - TenantPluginAutoUpgradeStrategy, - ) - ] - Account.metadata.create_all(sqlite_engine, tables=tables) - with Session(sqlite_engine, expire_on_commit=False) as session: - yield session - @pytest.fixture def mock_password_dependencies(self): """Mock setup for password-related functions.""" @@ -1264,24 +1246,6 @@ class TestRegisterService: - Error conditions and edge cases """ - @pytest.fixture - def sqlite_session(self, sqlite_engine) -> Iterator[Session]: - """SQLite session with the account/workspace tables registration flows touch.""" - tables = [ - model.metadata.tables[model.__tablename__] - for model in ( - Account, - AccountIntegrate, - Tenant, - TenantAccountJoin, - TenantPluginAutoUpgradeStrategy, - DifySetup, - ) - ] - Account.metadata.create_all(sqlite_engine, tables=tables) - with Session(sqlite_engine, expire_on_commit=False) as session: - yield session - @pytest.fixture def mock_redis_dependencies(self): """Mock setup for Redis-related functions.""" diff --git a/api/tests/unit_tests/test_sqlite_fixtures.py b/api/tests/unit_tests/test_sqlite_fixtures.py new file mode 100644 index 00000000000..c5a3fd660e1 --- /dev/null +++ b/api/tests/unit_tests/test_sqlite_fixtures.py @@ -0,0 +1,83 @@ +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Barrier + +import pytest +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.engine import URL, Engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import QueuePool + +import core.db.session_factory as session_factory_module +from models.account import Account +from models.base import TypeBase +from models.model import ExporleBanner + + +def test_sqlite_session_contains_the_full_registered_schema(sqlite_session: Session) -> None: + table_names = set(inspect(sqlite_session.get_bind()).get_table_names()) + + assert table_names == set(TypeBase.metadata.tables) + + +@pytest.mark.parametrize("sqlite_session", [(Account,)], indirect=True) +def test_sqlite_session_accepts_deferred_legacy_indirect_parameters(sqlite_session: Session) -> None: + """Prove legacy model parameters no longer limit the copied schema.""" + + assert inspect(sqlite_session.get_bind()).has_table(ExporleBanner.__tablename__) + + +def test_sqlite_engine_is_a_pristine_file_copy( + sqlite_engine: Engine, + request: pytest.FixtureRequest, +) -> None: + sqlite_database_template: Path = request.getfixturevalue("_sqlite_database_template") + assert isinstance(sqlite_engine.pool, QueuePool) + assert sqlite_engine.url.database != str(sqlite_database_template) + + with sqlite_engine.begin() as connection: + connection.execute(text("CREATE TABLE per_test_mutation (value INTEGER NOT NULL)")) + + template_engine = create_engine(URL.create("sqlite", database=str(sqlite_database_template))) + try: + assert not inspect(template_engine).has_table("per_test_mutation") + finally: + template_engine.dispose() + + +def test_core_session_factory_uses_the_shared_sqlite_session_factory( + sqlite_session_factory: sessionmaker[Session], +) -> None: + assert session_factory_module.session_factory.get_session_maker() is sqlite_session_factory + + with sqlite_session_factory.begin() as session: + session.execute(text("CREATE TABLE global_factory_probe (value INTEGER NOT NULL)")) + session.execute(text("INSERT INTO global_factory_probe (value) VALUES (42)")) + + with session_factory_module.session_factory.create_session() as session: + assert session.scalar(text("SELECT value FROM global_factory_probe")) == 42 + + +def test_sqlite_session_factory_shares_one_database_across_worker_sessions( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory.begin() as session: + session.execute(text("CREATE TABLE thread_probe (value INTEGER NOT NULL)")) + session.execute(text("INSERT INTO thread_probe (value) VALUES (42)")) + + worker_barrier = Barrier(2) + + def read_value() -> tuple[int, int]: + with sqlite_session_factory() as session: + connection = session.connection() + worker_barrier.wait(timeout=1) + value = session.scalar(text("SELECT value FROM thread_probe")) + connection_id = id(connection.connection.dbapi_connection) + return connection_id, value + + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(read_value) for _ in range(2)] + results = [future.result() for future in futures] + + assert {value for _, value in results} == {42} + assert len({connection_id for connection_id, _ in results}) == 2 From 460efbf285b558dd7a109705a70adb87a6afe070 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 17:19:32 +0900 Subject: [PATCH 036/531] test: use sqlite3 session in test_workflow_run_service (#38701) --- .../services/test_workflow_run_service.py | 134 +++++++++++------- 1 file changed, 82 insertions(+), 52 deletions(-) diff --git a/api/tests/unit_tests/services/test_workflow_run_service.py b/api/tests/unit_tests/services/test_workflow_run_service.py index fcfa9992cd1..b5902354165 100644 --- a/api/tests/unit_tests/services/test_workflow_run_service.py +++ b/api/tests/unit_tests/services/test_workflow_run_service.py @@ -1,11 +1,16 @@ +"""Workflow-run service tests with real SQLite-bound session factories.""" + +from decimal import Decimal from types import SimpleNamespace from typing import Any, cast from unittest.mock import MagicMock import pytest -from sqlalchemy import Engine +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session, sessionmaker -from models import Account, App, EndUser, WorkflowRunTriggeredFrom +from models import Account, App, EndUser, Message, WorkflowRunTriggeredFrom +from models.enums import ConversationFromSource from services import workflow_run_service as service_module from services.workflow_run_service import WorkflowRunService @@ -22,6 +27,11 @@ def repository_factory_mocks(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicMock return node_repo, workflow_run_repo, factory +@pytest.fixture +def sqlalchemy_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]: + return sessionmaker(bind=sqlite_engine, expire_on_commit=False) + + def _app_model(**kwargs: Any) -> App: return cast(App, SimpleNamespace(**kwargs)) @@ -34,13 +44,22 @@ def _end_user(**kwargs: Any) -> EndUser: return cast(EndUser, SimpleNamespace(**kwargs)) -def _fake_session_factory_returning_messages(messages: list[Any]) -> tuple[MagicMock, MagicMock]: - """Build a session factory whose session returns the given messages.""" - session = MagicMock() - session.scalars.return_value.all.return_value = messages - session_factory = MagicMock() - session_factory.return_value.__enter__.return_value = session - return session_factory, session +def _message(*, message_id: str, workflow_run_id: str, conversation_id: str) -> Message: + message = Message( + app_id="app-1", + conversation_id=conversation_id, + query="query", + message={"role": "user", "content": "query"}, + answer="answer", + message_unit_price=Decimal("0.0001"), + answer_unit_price=Decimal("0.0001"), + currency="USD", + from_source=ConversationFromSource.API, + ) + message.id = message_id + message._inputs = {} + message.workflow_run_id = workflow_run_id + return message class TestWorkflowRunServiceInitialization: @@ -48,59 +67,51 @@ class TestWorkflowRunServiceInitialization: self, monkeypatch: pytest.MonkeyPatch, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + sqlite_engine: Engine, ) -> None: - session_factory = MagicMock(name="session_factory") - sessionmaker_mock = MagicMock(return_value=session_factory) - monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock) - monkeypatch.setattr(service_module, "db", SimpleNamespace(engine="db-engine")) + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine)) service = WorkflowRunService() - sessionmaker_mock.assert_called_once_with(bind="db-engine", expire_on_commit=False) - assert service._session_factory is session_factory + assert isinstance(service._session_factory, sessionmaker) + assert service._session_factory.kw["bind"] is sqlite_engine + assert service._session_factory.kw["expire_on_commit"] is False def test___init___should_create_sessionmaker_when_engine_is_provided( self, - monkeypatch: pytest.MonkeyPatch, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + sqlite_engine: Engine, ) -> None: - class FakeEngine: - pass + service = WorkflowRunService(session_factory=sqlite_engine) - session_factory = MagicMock(name="session_factory") - sessionmaker_mock = MagicMock(return_value=session_factory) - monkeypatch.setattr(service_module, "Engine", FakeEngine) - monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock) - engine = cast(Engine, FakeEngine()) - - service = WorkflowRunService(session_factory=engine) - - sessionmaker_mock.assert_called_once_with(bind=engine, expire_on_commit=False) - assert service._session_factory is session_factory + assert isinstance(service._session_factory, sessionmaker) + assert service._session_factory.kw["bind"] is sqlite_engine + assert service._session_factory.kw["expire_on_commit"] is False def test___init___should_keep_provided_sessionmaker_and_create_repositories( self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: node_repo, workflow_run_repo, factory = repository_factory_mocks - session_factory = MagicMock(name="session_factory") - service = WorkflowRunService(session_factory=session_factory) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) - assert service._session_factory is session_factory + assert service._session_factory is sqlalchemy_session_factory assert service._node_execution_service_repo is node_repo assert service._workflow_run_repo is workflow_run_repo - factory.create_api_workflow_node_execution_repository.assert_called_once_with(session_factory) - factory.create_api_workflow_run_repository.assert_called_once_with(session_factory) + factory.create_api_workflow_node_execution_repository.assert_called_once_with(sqlalchemy_session_factory) + factory.create_api_workflow_run_repository.assert_called_once_with(sqlalchemy_session_factory) class TestWorkflowRunServiceQueries: def test_get_paginate_workflow_runs_should_forward_filters_and_parse_limit( self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: _, workflow_run_repo, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) app_model = _app_model(tenant_id="tenant-1", id="app-1") expected = MagicMock(name="pagination") workflow_run_repo.get_paginated_workflow_runs.return_value = expected @@ -122,20 +133,24 @@ class TestWorkflowRunServiceQueries: status="succeeded", ) + @pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) def test_get_paginate_advanced_chat_workflow_runs_should_attach_message_fields_when_message_exists( self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], monkeypatch: pytest.MonkeyPatch, + sqlalchemy_session_factory: sessionmaker[Session], + sqlite_session: Session, ) -> None: - message = SimpleNamespace(id="msg-1", conversation_id="conv-1", workflow_run_id="run-1") - session_factory, session = _fake_session_factory_returning_messages([message]) - service = WorkflowRunService(session_factory=session_factory) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) app_model = _app_model(tenant_id="tenant-1", id="app-1") run_with_message = SimpleNamespace(id="run-1", status="running") run_without_message = SimpleNamespace(id="run-2", status="succeeded") pagination = SimpleNamespace(data=[run_with_message, run_without_message]) monkeypatch.setattr(service, "get_paginate_workflow_runs", MagicMock(return_value=pagination)) + sqlite_session.add(_message(message_id="msg-1", conversation_id="conv-1", workflow_run_id="run-1")) + sqlite_session.commit() + result = service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={"limit": "2"}) assert result is pagination @@ -145,39 +160,49 @@ class TestWorkflowRunServiceQueries: assert result.data[0].status == "running" assert not hasattr(result.data[1], "message_id") assert result.data[1].id == "run-2" - # Messages are batch-loaded in a single query, not one per run. - session_factory.assert_called_once_with() - session.scalars.assert_called_once() + @pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) def test_get_paginate_advanced_chat_workflow_runs_batch_loads_messages_without_n_plus_one( self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], monkeypatch: pytest.MonkeyPatch, + sqlalchemy_session_factory: sessionmaker[Session], + sqlite_session: Session, ) -> None: """Messages must load with a constant query count regardless of run count. Previously the deprecated WorkflowRun.message property issued one query per run (N+1); they are now batch-loaded in a single query. """ - session_factory, session = _fake_session_factory_returning_messages([]) - service = WorkflowRunService(session_factory=session_factory) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) app_model = _app_model(tenant_id="tenant-1", id="app-1") runs = [SimpleNamespace(id=f"run-{i}", status="succeeded") for i in range(5)] pagination = SimpleNamespace(data=runs) monkeypatch.setattr(service, "get_paginate_workflow_runs", MagicMock(return_value=pagination)) - service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={}) + message_query_count = 0 - # Exactly one message query for the whole page, independent of run count. - session_factory.assert_called_once_with() - assert session.scalars.call_count == 1 + def count_message_query(*_args: object) -> None: + nonlocal message_query_count + message_query_count += 1 + + engine = sqlite_session.get_bind() + event.listen(engine, "before_cursor_execute", count_message_query) + try: + service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={}) + finally: + event.remove(engine, "before_cursor_execute", count_message_query) + + assert all(not hasattr(run, "message_id") for run in runs) + assert message_query_count == 1 def test_get_workflow_run_should_delegate_to_repository_by_tenant_and_app( self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: _, workflow_run_repo, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) app_model = _app_model(tenant_id="tenant-1", id="app-1") expected = MagicMock(name="workflow_run") workflow_run_repo.get_workflow_run_by_id.return_value = expected @@ -194,9 +219,10 @@ class TestWorkflowRunServiceQueries: def test_get_workflow_runs_count_should_forward_optional_filters( self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: _, workflow_run_repo, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) app_model = _app_model(tenant_id="tenant-1", id="app-1") expected = {"total": 3, "succeeded": 2} workflow_run_repo.get_workflow_runs_count.return_value = expected @@ -221,8 +247,9 @@ class TestWorkflowRunServiceQueries: self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], monkeypatch: pytest.MonkeyPatch, + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=None)) app_model = _app_model(id="app-1") user = _account(current_tenant_id="tenant-1") @@ -235,9 +262,10 @@ class TestWorkflowRunServiceQueries: self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], monkeypatch: pytest.MonkeyPatch, + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: node_repo, _, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) class FakeEndUser: @@ -267,9 +295,10 @@ class TestWorkflowRunServiceQueries: self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], monkeypatch: pytest.MonkeyPatch, + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: node_repo, _, _ = repository_factory_mocks - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) app_model = _app_model(id="app-1") user = _account(current_tenant_id="tenant-account") @@ -293,8 +322,9 @@ class TestWorkflowRunServiceQueries: self, repository_factory_mocks: tuple[MagicMock, MagicMock, Any], monkeypatch: pytest.MonkeyPatch, + sqlalchemy_session_factory: sessionmaker[Session], ) -> None: - service = WorkflowRunService(session_factory=MagicMock(name="session_factory")) + service = WorkflowRunService(session_factory=sqlalchemy_session_factory) monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1"))) app_model = _app_model(id="app-1") user = _account(current_tenant_id=None) From 71601bf76c917ad074be44f44bee5187f7e0f7ae Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 17:20:18 +0900 Subject: [PATCH 037/531] test: use sqlite3 session in test_draft_var_loader_simple (#38702) --- .../workflow/test_draft_var_loader_simple.py | 261 ++++++++++-------- 1 file changed, 140 insertions(+), 121 deletions(-) diff --git a/api/tests/unit_tests/services/workflow/test_draft_var_loader_simple.py b/api/tests/unit_tests/services/workflow/test_draft_var_loader_simple.py index fb5cf7bc6e5..dc7739d0e1c 100644 --- a/api/tests/unit_tests/services/workflow/test_draft_var_loader_simple.py +++ b/api/tests/unit_tests/services/workflow/test_draft_var_loader_simple.py @@ -1,32 +1,73 @@ """Simplified unit tests for DraftVarLoader focusing on core functionality.""" import json +from datetime import datetime from unittest.mock import Mock, patch import pytest from sqlalchemy import Engine +from sqlalchemy.orm import Session from core.workflow.file_reference import build_file_reference +from extensions.storage.storage_type import StorageType from graphon.file import File, FileTransferMethod, FileType from graphon.variables.segments import ObjectSegment, StringSegment from graphon.variables.types import SegmentType +from models.enums import CreatorUserRole from models.model import UploadFile from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile from services.workflow_draft_variable_service import DraftVarLoader +def _persist_offloaded_variable( + sqlite_session: Session, + *, + node_id: str, + name: str, +) -> WorkflowDraftVariable: + upload_file = UploadFile( + tenant_id="test-tenant-id", + storage_type=StorageType.LOCAL, + key=f"storage/key/{name}.txt", + name=f"{name}.txt", + size=10, + extension=".txt", + mime_type="text/plain", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="test-user-id", + created_at=datetime(2025, 1, 1), + used=True, + ) + variable_file = WorkflowDraftVariableFile( + tenant_id="test-tenant-id", + app_id="test-app-id", + user_id="test-user-id", + upload_file_id=upload_file.id, + size=10, + length=None, + value_type=SegmentType.STRING, + ) + draft_variable = WorkflowDraftVariable.new_node_variable( + app_id="test-app-id", + user_id="test-user-id", + node_id=node_id, + name=name, + value=StringSegment(value="truncated"), + node_execution_id=f"execution-{node_id}", + file_id=variable_file.id, + ) + sqlite_session.add_all([upload_file, variable_file, draft_variable]) + return draft_variable + + class TestDraftVarLoaderSimple: """Simplified unit tests for DraftVarLoader core methods.""" @pytest.fixture - def mock_engine(self) -> Engine: - return Mock(spec=Engine) - - @pytest.fixture - def draft_var_loader(self, mock_engine): + def draft_var_loader(self, sqlite_engine: Engine): """Create DraftVarLoader instance for testing.""" return DraftVarLoader( - engine=mock_engine, + engine=sqlite_engine, app_id="test-app-id", tenant_id="test-tenant-id", user_id="test-user-id", @@ -205,131 +246,109 @@ class TestDraftVarLoaderSimple: assert variable.value == rebuilt_file rebuild_file.assert_called_once_with(file_mapping=raw_file, tenant_id="tenant-1") - def test_load_variables_with_offloaded_variables_unit(self, draft_var_loader): + @pytest.mark.parametrize( + "sqlite_session", + [(WorkflowDraftVariable, WorkflowDraftVariableFile, UploadFile)], + indirect=True, + ) + def test_load_variables_with_offloaded_variables_unit( + self, + draft_var_loader: DraftVarLoader, + sqlite_session: Session, + ): """Test load_variables method with mix of regular and offloaded variables.""" selectors = [["node1", "regular_var"], ["node2", "offloaded_var"]] - - # Mock regular variable - regular_draft_var = Mock(spec=WorkflowDraftVariable) - regular_draft_var.is_truncated.return_value = False - regular_draft_var.node_id = "node1" - regular_draft_var.name = "regular_var" - regular_draft_var.get_value.return_value = StringSegment(value="regular_value") - regular_draft_var.get_selector.return_value = ["node1", "regular_var"] - regular_draft_var.id = "regular-var-id" + regular_draft_var = WorkflowDraftVariable.new_node_variable( + app_id="test-app-id", + user_id="test-user-id", + node_id="node1", + name="regular_var", + value=StringSegment(value="regular_value"), + node_execution_id="execution-node1", + ) regular_draft_var.description = "regular description" + offloaded_draft_var = _persist_offloaded_variable( + sqlite_session, + node_id="node2", + name="offloaded_var", + ) + distractor = WorkflowDraftVariable.new_node_variable( + app_id="test-app-id", + user_id="another-user", + node_id="node1", + name="regular_var", + value=StringSegment(value="wrong user"), + node_execution_id="execution-distractor", + ) + sqlite_session.add_all([regular_draft_var, distractor]) + sqlite_session.commit() - # Mock offloaded variable - upload_file = Mock(spec=UploadFile) - upload_file.key = "storage/key/offloaded.txt" + offloaded_variable = Mock() + offloaded_variable.id = offloaded_draft_var.id + offloaded_variable.selector = ["node2", "offloaded_var"] - variable_file = Mock(spec=WorkflowDraftVariableFile) - variable_file.value_type = SegmentType.STRING - variable_file.upload_file = upload_file + with ( + patch("services.workflow_draft_variable_service.StorageKeyLoader"), + patch.object( + draft_var_loader, + "_load_offloaded_variable", + return_value=(("node2", "offloaded_var"), offloaded_variable), + ) as load_offloaded, + patch("services.workflow_draft_variable_service.ThreadPoolExecutor") as executor_cls, + ): + executor = executor_cls.return_value.__enter__.return_value + executor.map.side_effect = lambda function, values: [function(value) for value in values] - offloaded_draft_var = Mock(spec=WorkflowDraftVariable) - offloaded_draft_var.is_truncated.return_value = True - offloaded_draft_var.node_id = "node2" - offloaded_draft_var.name = "offloaded_var" - offloaded_draft_var.get_selector.return_value = ["node2", "offloaded_var"] - offloaded_draft_var.variable_file = variable_file - offloaded_draft_var.id = "offloaded-var-id" - offloaded_draft_var.description = "offloaded description" + result = draft_var_loader.load_variables(selectors) - draft_vars = [regular_draft_var, offloaded_draft_var] + assert {variable.id for variable in result} == {regular_draft_var.id, offloaded_draft_var.id} + load_offloaded.assert_called_once() + loaded_offloaded = load_offloaded.call_args.args[0] + assert isinstance(loaded_offloaded, WorkflowDraftVariable) + assert loaded_offloaded.id == offloaded_draft_var.id + assert loaded_offloaded.variable_file is not None + assert loaded_offloaded.variable_file.upload_file is not None + assert loaded_offloaded.variable_file.upload_file.key == "storage/key/offloaded_var.txt" - with patch("services.workflow_draft_variable_service.Session") as mock_session_cls: - mock_session = Mock() - mock_session_cls.return_value.__enter__.return_value = mock_session - - mock_service = Mock() - mock_service.get_draft_variables_by_selectors.return_value = draft_vars - - with patch( - "services.workflow_draft_variable_service.WorkflowDraftVariableService", return_value=mock_service - ): - with patch("services.workflow_draft_variable_service.StorageKeyLoader"): - with patch("factories.variable_factory.segment_to_variable") as mock_segment_to_variable: - # Mock regular variable creation - regular_variable = Mock() - regular_variable.selector = ["node1", "regular_var"] - - # Mock offloaded variable creation - offloaded_variable = Mock() - offloaded_variable.selector = ["node2", "offloaded_var"] - - mock_segment_to_variable.return_value = regular_variable - - with patch("services.workflow_draft_variable_service.storage") as mock_storage: - mock_storage.load.return_value = b"offloaded_content" - - with patch.object(draft_var_loader, "_load_offloaded_variable") as mock_load_offloaded: - mock_load_offloaded.return_value = (("node2", "offloaded_var"), offloaded_variable) - - with patch("concurrent.futures.ThreadPoolExecutor") as mock_executor_cls: - mock_executor = Mock() - mock_executor_cls.return_value.__enter__.return_value = mock_executor - mock_executor.map.return_value = [(("node2", "offloaded_var"), offloaded_variable)] - - # Execute the method - result = draft_var_loader.load_variables(selectors) - - # Verify results - assert len(result) == 2 - - # Verify service method was called - mock_service.get_draft_variables_by_selectors.assert_called_once_with( - draft_var_loader._app_id, - selectors, - user_id=draft_var_loader._user_id, - ) - - # Verify offloaded variable loading was called - mock_load_offloaded.assert_called_once_with(offloaded_draft_var) - - def test_load_variables_all_offloaded_variables_unit(self, draft_var_loader): + @pytest.mark.parametrize( + "sqlite_session", + [(WorkflowDraftVariable, WorkflowDraftVariableFile, UploadFile)], + indirect=True, + ) + def test_load_variables_all_offloaded_variables_unit( + self, + draft_var_loader: DraftVarLoader, + sqlite_session: Session, + ): """Test load_variables method with only offloaded variables.""" selectors = [["node1", "offloaded_var1"], ["node2", "offloaded_var2"]] + offloaded_var1 = _persist_offloaded_variable( + sqlite_session, + node_id="node1", + name="offloaded_var1", + ) + offloaded_var2 = _persist_offloaded_variable( + sqlite_session, + node_id="node2", + name="offloaded_var2", + ) + sqlite_session.commit() - # Mock first offloaded variable - offloaded_var1 = Mock(spec=WorkflowDraftVariable) - offloaded_var1.is_truncated.return_value = True - offloaded_var1.node_id = "node1" - offloaded_var1.name = "offloaded_var1" + with ( + patch("services.workflow_draft_variable_service.StorageKeyLoader"), + patch("services.workflow_draft_variable_service.ThreadPoolExecutor") as executor_cls, + ): + executor = executor_cls.return_value.__enter__.return_value + executor.map.return_value = [ + (("node1", "offloaded_var1"), Mock()), + (("node2", "offloaded_var2"), Mock()), + ] - # Mock second offloaded variable - offloaded_var2 = Mock(spec=WorkflowDraftVariable) - offloaded_var2.is_truncated.return_value = True - offloaded_var2.node_id = "node2" - offloaded_var2.name = "offloaded_var2" + result = draft_var_loader.load_variables(selectors) - draft_vars = [offloaded_var1, offloaded_var2] - - with patch("services.workflow_draft_variable_service.Session") as mock_session_cls: - mock_session = Mock() - mock_session_cls.return_value.__enter__.return_value = mock_session - - mock_service = Mock() - mock_service.get_draft_variables_by_selectors.return_value = draft_vars - - with patch( - "services.workflow_draft_variable_service.WorkflowDraftVariableService", return_value=mock_service - ): - with patch("services.workflow_draft_variable_service.StorageKeyLoader"): - with patch("services.workflow_draft_variable_service.ThreadPoolExecutor") as mock_executor_cls: - mock_executor = Mock() - mock_executor_cls.return_value.__enter__.return_value = mock_executor - mock_executor.map.return_value = [ - (("node1", "offloaded_var1"), Mock()), - (("node2", "offloaded_var2"), Mock()), - ] - - # Execute the method - result = draft_var_loader.load_variables(selectors) - - # Verify results - since we have only offloaded variables, should have 2 results - assert len(result) == 2 - - # Verify ThreadPoolExecutor was used - mock_executor_cls.assert_called_once_with(max_workers=10) - mock_executor.map.assert_called_once() + assert len(result) == 2 + executor_cls.assert_called_once_with(max_workers=10) + executor.map.assert_called_once() + loaded_draft_vars = executor.map.call_args.args[1] + assert {variable.id for variable in loaded_draft_vars} == {offloaded_var1.id, offloaded_var2.id} + assert all(variable.variable_file.upload_file is not None for variable in loaded_draft_vars) From 97acd9c70b949b457af32fff99678ef0b0ba302a Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 17:21:39 +0900 Subject: [PATCH 038/531] test: use sqlite3 session in test_utils (#38729) --- api/tests/unit_tests/core/ops/test_utils.py | 58 +++++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/api/tests/unit_tests/core/ops/test_utils.py b/api/tests/unit_tests/core/ops/test_utils.py index 8a89422782e..6960a11f415 100644 --- a/api/tests/unit_tests/core/ops/test_utils.py +++ b/api/tests/unit_tests/core/ops/test_utils.py @@ -1,9 +1,11 @@ import re from datetime import datetime -from unittest.mock import MagicMock, patch +from decimal import Decimal import pytest +from sqlalchemy.orm import Session +import core.ops.utils as utils_module from core.ops.utils import ( filter_none_values, generate_dotted_order, @@ -15,6 +17,42 @@ from core.ops.utils import ( validate_url, validate_url_with_path, ) +from models.enums import ConversationFromSource +from models.model import Message + + +class _DatabaseBinding: + """Expose the real SQLite session used by the message lookup helper.""" + + session: Session + + def __init__(self, session: Session) -> None: + self.session = session + + +@pytest.fixture +def message_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session: + """Bind the message lookup helper to the shared SQLite test session.""" + + monkeypatch.setattr(utils_module, "db", _DatabaseBinding(sqlite_session)) + return sqlite_session + + +def _message(message_id: str) -> Message: + message = Message( + id=message_id, + app_id="app-id", + conversation_id="conversation-id", + query="question", + message={"role": "user", "content": "question"}, + answer="answer", + message_unit_price=Decimal("0.0001"), + answer_unit_price=Decimal("0.0001"), + currency="USD", + from_source=ConversationFromSource.API, + ) + message._inputs = {} + return message class TestValidateUrl: @@ -220,22 +258,20 @@ class TestFilterNoneValues: assert filter_none_values({}) == {} +@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) class TestGetMessageData: """Test cases for get_message_data function""" - @patch("core.ops.utils.db") - @patch("core.ops.utils.Message") - @patch("core.ops.utils.select") - def test_get_message_data(self, mock_select, mock_message, mock_db): - mock_scalar = mock_db.session.scalar - mock_msg_instance = MagicMock() - mock_scalar.return_value = mock_msg_instance + def test_get_message_data(self, message_session: Session): + target = _message("message-id") + unrelated = _message("other-message-id") + message_session.add_all((target, unrelated)) + message_session.commit() result = get_message_data("message-id") - assert result == mock_msg_instance - mock_select.assert_called_once() - mock_scalar.assert_called_once() + assert result is target + assert result.id == "message-id" class TestMeasureTime: From a1c9564b303f675719fd994f104b0af723d420fa Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 17:22:16 +0900 Subject: [PATCH 039/531] test: use sqlite3 session in test_llm_generator_missing (#38742) --- .../test_llm_generator_missing.py | 182 ++++++++++++------ 1 file changed, 126 insertions(+), 56 deletions(-) diff --git a/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py b/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py index d193edc9fd5..fc890f778c9 100644 --- a/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py +++ b/api/tests/unit_tests/core/llm_generator/test_llm_generator_missing.py @@ -1,7 +1,57 @@ -import sys +from datetime import datetime, timedelta from unittest.mock import MagicMock, patch +import pytest +from sqlalchemy import event +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session, sessionmaker + +import core.llm_generator.llm_generator as generator_module from core.llm_generator.llm_generator import LLMGenerator, _parse_string_list +from core.model_manager import ModelInstance, ModelManager +from core.workflow.generator import tool_catalogue as tool_catalogue_module +from core.workflow.generator.tool_catalogue import ToolCatalogueEntry +from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage +from graphon.model_runtime.entities.message_entities import AssistantPromptMessage +from models.dataset import Dataset +from services.workflow_service import WorkflowService + + +@pytest.fixture +def dataset_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session: + """Bind the real SQLite session to the production database extension.""" + + monkeypatch.setattr(generator_module.db, "session", sqlite_session) + return sqlite_session + + +def _llm_result(content: str) -> LLMResult: + """Build a real non-streaming LLM response around deterministic test content.""" + + return LLMResult( + model="test-model", + message=AssistantPromptMessage(content=content), + usage=LLMUsage.empty_usage(), + ) + + +def _model_manager() -> tuple[MagicMock, MagicMock]: + """Build spec-constrained mocks for the model-manager boundary and its default model.""" + + model_manager = MagicMock(spec=ModelManager) + model_instance = MagicMock(spec=ModelInstance) + model_manager.get_default_model_instance.return_value = model_instance + return model_manager, model_instance + + +def _dataset(*, dataset_id: str, tenant_id: str, name: str, created_at: datetime) -> Dataset: + return Dataset( + id=dataset_id, + tenant_id=tenant_id, + name=name, + created_by="account-id", + created_at=created_at, + ) class TestParseStringList: @@ -34,95 +84,115 @@ class TestParseStringList: class TestGenerateWorkflowInstructionSuggestions: @patch("core.llm_generator.llm_generator.ModelManager.for_tenant") def test_no_default_model(self, mock_for_tenant): - mock_for_tenant.return_value.get_default_model_instance.side_effect = Exception("No model") + model_manager, _ = _model_manager() + model_manager.get_default_model_instance.side_effect = RuntimeError("no default model") + mock_for_tenant.return_value = model_manager + assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == [] @patch("core.llm_generator.llm_generator.ModelManager.for_tenant") @patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context") def test_llm_success(self, mock_build_context, mock_for_tenant): mock_build_context.return_value = "context" - - mock_model = MagicMock() - mock_model.invoke_llm.return_value = MagicMock() - mock_model.invoke_llm.return_value.message.get_text_content.return_value = '["idea 1", "idea 2"]' - - mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model + model_manager, model_instance = _model_manager() + model_instance.invoke_llm.return_value = _llm_result('["idea 1", "idea 2"]') + mock_for_tenant.return_value = model_manager result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") assert result == ["idea 1", "idea 2"] + model_instance.invoke_llm.assert_called_once() @patch("core.llm_generator.llm_generator.ModelManager.for_tenant") @patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context") def test_llm_error(self, mock_build_context, mock_for_tenant): mock_build_context.return_value = "context" + model_manager, model_instance = _model_manager() + model_instance.invoke_llm.side_effect = RuntimeError("API error") + mock_for_tenant.return_value = model_manager - mock_model = MagicMock() - mock_model.invoke_llm.side_effect = Exception("API error") - - mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model - - assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == [] + result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") + assert result == [] + model_instance.invoke_llm.assert_called_once() @patch("core.llm_generator.llm_generator.ModelManager.for_tenant") @patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context") def test_llm_bad_output(self, mock_build_context, mock_for_tenant): mock_build_context.return_value = "context" + model_manager, model_instance = _model_manager() + model_instance.invoke_llm.return_value = _llm_result("Not a list") + mock_for_tenant.return_value = model_manager - mock_model = MagicMock() - mock_model.invoke_llm.return_value = MagicMock() - mock_model.invoke_llm.return_value.message.get_text_content.return_value = "Not a list" - - mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model - - assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == [] + result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") + assert result == [] + model_instance.invoke_llm.assert_called_once() +@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True) class TestBuildSuggestionContext: - @patch("core.llm_generator.llm_generator.db.session.scalars") - def test_both_success(self, mock_scalars, monkeypatch): - mock_scalars.return_value.all.return_value = ["kb1", "kb2"] + def test_both_success(self, dataset_session: Session, monkeypatch: pytest.MonkeyPatch): + now = datetime.now() + dataset_session.add_all( + ( + _dataset(dataset_id="kb-1", tenant_id="tenant", name="kb1", created_at=now), + _dataset( + dataset_id="kb-2", + tenant_id="tenant", + name="kb2", + created_at=now - timedelta(seconds=1), + ), + _dataset(dataset_id="other-kb", tenant_id="other", name="private", created_at=now), + ) + ) + dataset_session.commit() - # ``_build_suggestion_context`` imports the tool catalogue lazily, so we - # stub the module in ``sys.modules``. Use ``monkeypatch.setitem`` so the - # ORIGINAL module is RESTORED on teardown — a bare ``del`` would evict it - # from sys.modules entirely, after which a sibling test that imported - # ``build_tool_catalogue`` at collection time (e.g. test_tool_catalogue) - # diverges from a freshly re-imported module and its @patch targets stop - # applying, silently breaking it under xdist. - mock_tool_catalogue = MagicMock() - mock_tool_catalogue.build_tool_catalogue.return_value = "catalog" - mock_tool_catalogue.format_tool_catalogue.return_value = "tool1\ntool2" - monkeypatch.setitem(sys.modules, "core.workflow.generator.tool_catalogue", mock_tool_catalogue) + def build_tool_catalogue(_tenant_id: str) -> list[ToolCatalogueEntry]: + return [ + ToolCatalogueEntry( + provider_name="provider", + provider_type="builtin", + plugin_id="", + tool_name="tool1", + tool_label="tool1", + description="First tool", + ), + ToolCatalogueEntry( + provider_name="provider", + provider_type="builtin", + plugin_id="", + tool_name="tool2", + tool_label="tool2", + description="Second tool", + ), + ] + + # Keep the real module and formatter; only isolate provider/plugin discovery. + monkeypatch.setattr(tool_catalogue_module, "build_tool_catalogue", build_tool_catalogue) result = LLMGenerator._build_suggestion_context("tenant") assert "Knowledge bases:\n- kb1\n- kb2" in result - assert "Installed tools:\ntool1\ntool2" in result + assert "Installed tools:\n- provider/tool1 — First tool\n- provider/tool2 — Second tool" in result - @patch("core.llm_generator.llm_generator.db.session.scalars") - def test_both_fail(self, mock_scalars, monkeypatch): - mock_scalars.side_effect = Exception("DB error") + def test_both_fail(self, dataset_session: Session, monkeypatch: pytest.MonkeyPatch): + def fail_query(_orm_execute_state: object) -> None: + raise SQLAlchemyError("DB error") - # See ``test_both_success``: restore the original module via monkeypatch - # rather than ``del``-ing it, so we don't evict it for sibling tests. - mock_tool_catalogue = MagicMock() - mock_tool_catalogue.build_tool_catalogue.side_effect = Exception("Tool error") - monkeypatch.setitem(sys.modules, "core.workflow.generator.tool_catalogue", mock_tool_catalogue) + def fail_tool_catalogue(_tenant_id: str) -> list[ToolCatalogueEntry]: + raise RuntimeError("Tool error") - assert LLMGenerator._build_suggestion_context("tenant") == "" + event.listen(dataset_session, "do_orm_execute", fail_query) + monkeypatch.setattr(tool_catalogue_module, "build_tool_catalogue", fail_tool_catalogue) + + try: + assert LLMGenerator._build_suggestion_context("tenant") == "" + finally: + event.remove(dataset_session, "do_orm_execute", fail_query) class TestWorkflowServiceInterface: - def test_protocol_methods(self): - # Just to cover the 'pass' statements in the Protocol definition + def test_real_workflow_service_exposes_protocol_methods(self): from core.llm_generator.llm_generator import WorkflowServiceInterface - class MockService(WorkflowServiceInterface): - def get_draft_workflow(self, app_model, workflow_id=None, *, session): - return super().get_draft_workflow(app_model, workflow_id, session=session) + service: WorkflowServiceInterface = WorkflowService(sessionmaker()) - def get_node_last_run(self, app_model, workflow, node_id): - return super().get_node_last_run(app_model, workflow, node_id) - - service = MockService() - service.get_draft_workflow(None, session=None) - service.get_node_last_run(None, None, "node") + assert callable(service.get_draft_workflow) + assert callable(service.get_node_last_run) From 755f7b0e8b0b5df4d89ea8062790682b3e71e9a6 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 17:22:45 +0900 Subject: [PATCH 040/531] test: use sqlite3 session in test_workflow_comment_service (#38700) --- .../services/test_workflow_comment_service.py | 1036 +++++++++-------- 1 file changed, 576 insertions(+), 460 deletions(-) diff --git a/api/tests/unit_tests/services/test_workflow_comment_service.py b/api/tests/unit_tests/services/test_workflow_comment_service.py index e6db068e07c..0478351b073 100644 --- a/api/tests/unit_tests/services/test_workflow_comment_service.py +++ b/api/tests/unit_tests/services/test_workflow_comment_service.py @@ -1,35 +1,129 @@ -from unittest.mock import MagicMock, Mock, patch +"""Persistence-focused tests for :mod:`services.workflow_comment_service`. + +The service opens its own sessions for most operations, so these tests bind it to the +same disposable SQLite engine used for fixture setup and assert committed database +state. External task dispatch and the clock remain mocked at their I/O boundaries. +""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import Mock, patch import pytest +from sqlalchemy import event, func, select +from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound +from models import App, TenantAccountJoin, WorkflowComment, WorkflowCommentMention, WorkflowCommentReply +from models.account import Account, TenantAccountRole +from models.model import AppMode from services import workflow_comment_service as service_module from services.workflow_comment_service import WorkflowCommentService +TENANT_ID = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT_ID = "11111111-1111-1111-1111-111111111112" +APP_ID = "22222222-2222-2222-2222-222222222222" +OTHER_APP_ID = "22222222-2222-2222-2222-222222222223" +OWNER_ID = "33333333-3333-3333-3333-333333333333" +USER_2_ID = "33333333-3333-3333-3333-333333333334" +USER_3_ID = "33333333-3333-3333-3333-333333333335" +USER_4_ID = "33333333-3333-3333-3333-333333333336" +OUTSIDER_ID = "33333333-3333-3333-3333-333333333337" + @pytest.fixture -def mock_session(monkeypatch: pytest.MonkeyPatch) -> Mock: - session = Mock() - context_manager = MagicMock() - context_manager.__enter__.return_value = session - context_manager.__exit__.return_value = False - mock_db = MagicMock() - mock_db.engine = Mock() - empty_scalars = Mock() - empty_scalars.all.return_value = [] - session.scalars.return_value = empty_scalars - monkeypatch.setattr(service_module, "Session", Mock(return_value=context_manager)) - monkeypatch.setattr(service_module, "db", mock_db) - monkeypatch.setattr(service_module.send_workflow_comment_mention_email_task, "delay", Mock()) - return session +def delay_mock(monkeypatch: pytest.MonkeyPatch) -> Mock: + mock = Mock() + monkeypatch.setattr(service_module.send_workflow_comment_mention_email_task, "delay", mock) + return mock -def _mock_scalars(result_list: list[object]) -> Mock: - scalars = Mock() - scalars.all.return_value = result_list - return scalars +@pytest.fixture(autouse=True) +def bind_service_database( + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, + delay_mock: Mock, +) -> None: + """Bind service-owned sessions to the engine prepared by the shared SQLite fixture.""" + monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_session.get_bind())) +def _account( + account_id: str, + *, + name: str = "Test User", + email: str = "user@example.com", + interface_language: str | None = "en-US", +) -> Account: + account = Account(name=name, email=email, interface_language=interface_language) + account.id = account_id + return account + + +def _app(*, app_id: str = APP_ID, tenant_id: str = TENANT_ID, name: str = "My App") -> App: + app = App( + tenant_id=tenant_id, + name=name, + mode=AppMode.WORKFLOW, + enable_site=False, + enable_api=False, + created_by=OWNER_ID, + ) + app.id = app_id + return app + + +def _comment( + *, + tenant_id: str = TENANT_ID, + app_id: str = APP_ID, + created_by: str = OWNER_ID, + content: str = "hello", + resolved: bool = False, + resolved_at: datetime | None = None, + resolved_by: str | None = None, +) -> WorkflowComment: + return WorkflowComment( + tenant_id=tenant_id, + app_id=app_id, + position_x=1.0, + position_y=2.0, + content=content, + created_by=created_by, + resolved=resolved, + resolved_at=resolved_at, + resolved_by=resolved_by, + ) + + +def _membership(account_id: str, *, tenant_id: str = TENANT_ID) -> TenantAccountJoin: + return TenantAccountJoin( + tenant_id=tenant_id, + account_id=account_id, + role=TenantAccountRole.NORMAL, + ) + + +def _persist(session: Session, *objects: object) -> None: + session.add_all(objects) + session.commit() + + +@pytest.mark.usefixtures("sqlite_session") +@pytest.mark.parametrize( + "sqlite_session", + [ + ( + Account, + TenantAccountJoin, + App, + WorkflowComment, + WorkflowCommentReply, + WorkflowCommentMention, + ) + ], + indirect=True, +) class TestWorkflowCommentService: def test_validate_content_rejects_empty(self) -> None: with pytest.raises(ValueError): @@ -39,42 +133,43 @@ class TestWorkflowCommentService: with pytest.raises(ValueError): WorkflowCommentService._validate_content("a" * 1001) - def test_filter_valid_mentioned_user_ids_filters_by_tenant_and_preserves_order(self, mock_session: Mock) -> None: - tenant_member_1 = "123e4567-e89b-12d3-a456-426614174000" - tenant_member_2 = "123e4567-e89b-12d3-a456-426614174002" - non_tenant_member = "123e4567-e89b-12d3-a456-426614174001" - mock_session.scalars.return_value = _mock_scalars([tenant_member_1, tenant_member_2]) + def test_filter_valid_mentioned_user_ids_filters_by_tenant_and_preserves_order( + self, sqlite_session: Session + ) -> None: + _persist( + sqlite_session, + _membership(OWNER_ID), + _membership(USER_2_ID), + _membership(USER_3_ID, tenant_id=OTHER_TENANT_ID), + ) result = WorkflowCommentService._filter_valid_mentioned_user_ids( [ - tenant_member_1, + OWNER_ID, "", 123, # type: ignore[list-item] - tenant_member_1, - non_tenant_member, - tenant_member_2, + OWNER_ID, + USER_3_ID, + USER_2_ID, ], - session=mock_session, - tenant_id="tenant-1", + session=sqlite_session, + tenant_id=TENANT_ID, ) - assert result == [ - tenant_member_1, - tenant_member_2, - ] + assert result == [OWNER_ID, USER_2_ID] def test_format_comment_excerpt_handles_short_and_long_limits(self) -> None: assert WorkflowCommentService._format_comment_excerpt(" hello ", max_length=10) == "hello" assert WorkflowCommentService._format_comment_excerpt("abcdefghijk", max_length=3) == "abc" assert WorkflowCommentService._format_comment_excerpt(" abcdefghijk ", max_length=8) == "abcde..." - def test_build_mention_email_payloads_returns_empty_for_no_candidates(self, mock_session: Mock) -> None: + def test_build_mention_email_payloads_returns_empty_for_no_candidates(self, sqlite_session: Session) -> None: assert ( WorkflowCommentService._build_mention_email_payloads( - session=mock_session, - tenant_id="tenant-1", - app_id="app-1", - mentioner_id="user-1", + session=sqlite_session, + tenant_id=TENANT_ID, + app_id=APP_ID, + mentioner_id=OWNER_ID, mentioned_user_ids=[], content="hello", ) @@ -82,11 +177,11 @@ class TestWorkflowCommentService: ) assert ( WorkflowCommentService._build_mention_email_payloads( - session=mock_session, - tenant_id="tenant-1", - app_id="app-1", - mentioner_id="user-1", - mentioned_user_ids=["user-1"], + session=sqlite_session, + tenant_id=TENANT_ID, + app_id=APP_ID, + mentioner_id=OWNER_ID, + mentioned_user_ids=[OWNER_ID], content="hello", ) == [] @@ -104,29 +199,26 @@ class TestWorkflowCommentService: assert delay_mock.call_count == 2 - def test_build_mention_email_payloads_skips_accounts_without_email(self, mock_session: Mock) -> None: - account_without_email = Mock() - account_without_email.email = None - account_without_email.name = "No Email" - account_without_email.interface_language = "en-US" - - account_with_email = Mock() - account_with_email.email = "user@example.com" - account_with_email.name = "" - account_with_email.interface_language = None - - mock_session.scalar.side_effect = ["My App", "Commenter"] - mock_session.scalars.return_value = _mock_scalars([account_without_email, account_with_email]) + def test_build_mention_email_payloads_skips_accounts_without_email(self, sqlite_session: Session) -> None: + _persist( + sqlite_session, + _app(), + _account(OWNER_ID, name="Commenter", email="commenter@example.com"), + _account(USER_2_ID, name="No Email", email=""), + _account(USER_3_ID, name="", email="user@example.com", interface_language=None), + _membership(USER_2_ID), + _membership(USER_3_ID), + ) payloads = WorkflowCommentService._build_mention_email_payloads( - session=mock_session, - tenant_id="tenant-1", - app_id="app-1", - mentioner_id="user-1", - mentioned_user_ids=["user-2"], + session=sqlite_session, + tenant_id=TENANT_ID, + app_id=APP_ID, + mentioner_id=OWNER_ID, + mentioned_user_ids=[USER_2_ID, USER_3_ID], content="hello", ) - expected_app_url = f"{service_module.dify_config.CONSOLE_WEB_URL.rstrip('/')}/app/app-1/workflow" + expected_app_url = f"{service_module.dify_config.CONSOLE_WEB_URL.rstrip('/')}/app/{APP_ID}/workflow" assert payloads == [ { @@ -140,439 +232,463 @@ class TestWorkflowCommentService: } ] - def test_create_comment_creates_mentions(self, mock_session: Mock) -> None: - comment = Mock() - comment.id = "comment-1" - comment.created_at = "ts" + def test_create_comment_creates_mentions(self, sqlite_session: Session) -> None: + _persist(sqlite_session, _membership(USER_2_ID)) - with ( - patch.object(service_module, "WorkflowComment", return_value=comment), - patch.object(service_module, "WorkflowCommentMention", return_value=Mock()), - patch.object(WorkflowCommentService, "_filter_valid_mentioned_user_ids", return_value=["user-2"]), - ): - result = WorkflowCommentService.create_comment( - tenant_id="tenant-1", - app_id="app-1", - created_by="user-1", - content="hello", - position_x=1.0, - position_y=2.0, - mentioned_user_ids=["user-2", "bad-id"], - ) + result = WorkflowCommentService.create_comment( + tenant_id=TENANT_ID, + app_id=APP_ID, + created_by=OWNER_ID, + content="hello", + position_x=1.0, + position_y=2.0, + mentioned_user_ids=[USER_2_ID, OUTSIDER_ID], + ) - assert result == {"id": "comment-1", "created_at": "ts"} - assert mock_session.add.call_args_list[0].args[0] is comment - assert mock_session.add.call_count == 2 - mock_session.commit.assert_called_once() - - def test_update_comment_raises_not_found(self, mock_session: Mock) -> None: - mock_session.scalar.return_value = None + comment = sqlite_session.get(WorkflowComment, result["id"]) + assert comment is not None + assert comment.content == "hello" + assert comment.created_at == result["created_at"] + mentions = sqlite_session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.comment_id == comment.id) + ).all() + assert [mention.mentioned_user_id for mention in mentions] == [USER_2_ID] + def test_update_comment_raises_not_found(self, sqlite_session: Session) -> None: with pytest.raises(NotFound): WorkflowCommentService.update_comment( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - user_id="user-1", + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id="missing-comment", + user_id=OWNER_ID, content="hello", ) - def test_update_comment_raises_forbidden(self, mock_session: Mock) -> None: - comment = Mock() - comment.created_by = "owner" - mock_session.scalar.return_value = comment + def test_update_comment_raises_forbidden(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) with pytest.raises(Forbidden): WorkflowCommentService.update_comment( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - user_id="intruder", + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + user_id=OUTSIDER_ID, content="hello", ) - def test_update_comment_replaces_mentions(self, mock_session: Mock) -> None: - comment = Mock() - comment.id = "comment-1" - comment.created_by = "owner" - mock_session.scalar.return_value = comment + def test_update_comment_replaces_mentions(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + _persist( + sqlite_session, + WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_3_ID), + WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_4_ID), + _membership(USER_2_ID), + ) - existing_mentions = [Mock(), Mock()] - mock_session.scalars.return_value = _mock_scalars(existing_mentions) + result = WorkflowCommentService.update_comment( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + user_id=OWNER_ID, + content="updated", + mentioned_user_ids=[USER_2_ID, OUTSIDER_ID], + ) - with patch.object(WorkflowCommentService, "_filter_valid_mentioned_user_ids", return_value=["user-2"]): - result = WorkflowCommentService.update_comment( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - user_id="owner", - content="updated", - mentioned_user_ids=["user-2", "bad-id"], - ) + sqlite_session.expire_all() + persisted_comment = sqlite_session.get(WorkflowComment, comment.id) + assert persisted_comment is not None + assert persisted_comment.content == "updated" + assert result == {"id": comment.id, "updated_at": persisted_comment.updated_at} + mentions = sqlite_session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.comment_id == comment.id) + ).all() + assert [mention.mentioned_user_id for mention in mentions] == [USER_2_ID] - assert result == {"id": "comment-1", "updated_at": comment.updated_at} - assert mock_session.delete.call_count == 2 - assert mock_session.add.call_count == 1 - mock_session.commit.assert_called_once() - - def test_update_comment_preserves_mentions_when_mentioned_user_ids_omitted(self, mock_session: Mock) -> None: - comment = Mock() - comment.id = "comment-1" - comment.created_by = "owner" - mock_session.scalar.return_value = comment - - with ( - patch.object(WorkflowCommentService, "_filter_valid_mentioned_user_ids") as filter_mentions_mock, - patch.object(WorkflowCommentService, "_build_mention_email_payloads") as build_payloads_mock, - patch.object(WorkflowCommentService, "_dispatch_mention_emails") as dispatch_mock, - ): - result = WorkflowCommentService.update_comment( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - user_id="owner", - content="updated", - ) - - assert result == {"id": "comment-1", "updated_at": comment.updated_at} - mock_session.delete.assert_not_called() - mock_session.add.assert_not_called() - filter_mentions_mock.assert_not_called() - build_payloads_mock.assert_not_called() - dispatch_mock.assert_called_once_with([]) - mock_session.commit.assert_called_once() - - def test_update_comment_clears_mentions_when_empty_list_provided(self, mock_session: Mock) -> None: - comment = Mock() - comment.id = "comment-1" - comment.created_by = "owner" - mock_session.scalar.return_value = comment - - existing_mentions = [Mock(), Mock()] - mock_session.scalars.return_value = _mock_scalars(existing_mentions) - - with patch.object(WorkflowCommentService, "_filter_valid_mentioned_user_ids", return_value=[]): - result = WorkflowCommentService.update_comment( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - user_id="owner", - content="updated", - mentioned_user_ids=[], - ) - - assert result == {"id": "comment-1", "updated_at": comment.updated_at} - assert mock_session.delete.call_count == 2 - mock_session.add.assert_not_called() - mock_session.commit.assert_called_once() - - def test_update_comment_notifies_only_new_mentions(self, mock_session: Mock) -> None: - comment = Mock() - comment.id = "comment-1" - comment.created_by = "owner" - mock_session.scalar.return_value = comment - - existing_mention = Mock() - existing_mention.mentioned_user_id = "user-2" - mock_session.scalars.return_value = _mock_scalars([existing_mention]) - - with ( - patch.object( - WorkflowCommentService, - "_filter_valid_mentioned_user_ids", - return_value=["user-2", "user-3"], - ), - patch.object( - WorkflowCommentService, - "_build_mention_email_payloads", - return_value=[], - ) as build_payloads_mock, - patch.object(WorkflowCommentService, "_dispatch_mention_emails") as dispatch_mock, - ): - WorkflowCommentService.update_comment( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - user_id="owner", - content="updated", - mentioned_user_ids=["user-2", "user-3"], - ) - - assert build_payloads_mock.call_args.kwargs["mentioned_user_ids"] == ["user-3"] - dispatch_mock.assert_called_once_with([]) - - def test_get_comments_preloads_related_accounts(self, mock_session: Mock) -> None: - comment = Mock() - comment.created_by = "user-1" - comment.resolved_by = "user-2" - reply = Mock() - reply.created_by = "user-3" - mention = Mock() - mention.mentioned_user_id = "user-4" - comment.replies = [reply] - comment.mentions = [mention] - comment.cache_created_by_account = Mock() - comment.cache_resolved_by_account = Mock() - reply.cache_created_by_account = Mock() - mention.cache_mentioned_user_account = Mock() - - account_1 = Mock() - account_1.id = "user-1" - account_2 = Mock() - account_2.id = "user-2" - account_3 = Mock() - account_3.id = "user-3" - account_4 = Mock() - account_4.id = "user-4" - - mock_session.scalars.side_effect = [ - _mock_scalars([comment]), - _mock_scalars([account_1, account_2, account_3, account_4]), - ] - - result = WorkflowCommentService.get_comments("tenant-1", "app-1") - - assert result == [comment] - comment.cache_created_by_account.assert_called_once_with(account_1) - comment.cache_resolved_by_account.assert_called_once_with(account_2) - reply.cache_created_by_account.assert_called_once_with(account_3) - mention.cache_mentioned_user_account.assert_called_once_with(account_4) - - def test_preload_accounts_returns_early_for_empty_comments(self, mock_session: Mock) -> None: - WorkflowCommentService._preload_accounts(mock_session, []) - - mock_session.scalars.assert_not_called() - - def test_get_comment_raises_not_found_with_provided_session(self) -> None: - session = Mock() - session.scalar.return_value = None - - with pytest.raises(NotFound): - WorkflowCommentService.get_comment("tenant-1", "app-1", "comment-1", session=session) - - def test_get_comment_uses_context_manager_when_session_not_provided(self, mock_session: Mock) -> None: - comment = Mock() - comment.created_by = "user-1" - comment.resolved_by = None - comment.replies = [] - comment.mentions = [] - comment.cache_created_by_account = Mock() - comment.cache_resolved_by_account = Mock() - mock_session.scalar.return_value = comment - mock_session.scalars.return_value = _mock_scalars([]) - - result = WorkflowCommentService.get_comment("tenant-1", "app-1", "comment-1") - - assert result is comment - comment.cache_created_by_account.assert_called_once() - comment.cache_resolved_by_account.assert_called_once_with(None) - - def test_delete_comment_raises_forbidden(self, mock_session: Mock) -> None: - comment = Mock() - comment.created_by = "owner" - - with patch.object(WorkflowCommentService, "get_comment", return_value=comment): - with pytest.raises(Forbidden): - WorkflowCommentService.delete_comment("tenant-1", "app-1", "comment-1", "intruder") - - def test_delete_comment_removes_related_entities(self, mock_session: Mock) -> None: - comment = Mock() - comment.created_by = "owner" - - mentions = [Mock(), Mock()] - replies = [Mock()] - mock_session.scalars.side_effect = [_mock_scalars(mentions), _mock_scalars(replies)] - - with patch.object(WorkflowCommentService, "get_comment", return_value=comment): - WorkflowCommentService.delete_comment("tenant-1", "app-1", "comment-1", "owner") - - assert mock_session.delete.call_count == 4 - mock_session.commit.assert_called_once() - - def test_resolve_comment_sets_fields(self, mock_session: Mock) -> None: - comment = Mock() - comment.resolved = False - comment.resolved_at = None - comment.resolved_by = None - - with ( - patch.object(WorkflowCommentService, "get_comment", return_value=comment), - patch.object(service_module, "naive_utc_now", return_value="now"), - ): - result = WorkflowCommentService.resolve_comment("tenant-1", "app-1", "comment-1", "user-1") - - assert result is comment - assert comment.resolved is True - assert comment.resolved_at == "now" - assert comment.resolved_by == "user-1" - mock_session.commit.assert_called_once() - - def test_resolve_comment_noop_when_already_resolved(self, mock_session: Mock) -> None: - comment = Mock() - comment.resolved = True - - with patch.object(WorkflowCommentService, "get_comment", return_value=comment): - result = WorkflowCommentService.resolve_comment("tenant-1", "app-1", "comment-1", "user-1") - - assert result is comment - mock_session.commit.assert_not_called() - - def test_create_reply_requires_comment(self, mock_session: Mock) -> None: - mock_session.get.return_value = None - - with pytest.raises(NotFound): - WorkflowCommentService.create_reply("comment-1", "hello", "user-1") - - def test_create_reply_creates_mentions(self, mock_session: Mock) -> None: - mock_session.get.return_value = Mock() - reply = Mock() - reply.id = "reply-1" - reply.created_at = "ts" - - with ( - patch.object(service_module, "WorkflowCommentReply", return_value=reply), - patch.object(service_module, "WorkflowCommentMention", return_value=Mock()), - patch.object(WorkflowCommentService, "_filter_valid_mentioned_user_ids", return_value=["user-2"]), - ): - result = WorkflowCommentService.create_reply( - comment_id="comment-1", - content="hello", - created_by="user-1", - mentioned_user_ids=["user-2", "bad-id"], - ) - - assert result == {"id": "reply-1", "created_at": "ts"} - assert mock_session.add.call_count == 2 - mock_session.commit.assert_called_once() - - def test_update_reply_raises_not_found(self, mock_session: Mock) -> None: - mock_session.scalar.return_value = None - - with pytest.raises(NotFound): - WorkflowCommentService.update_reply( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - reply_id="reply-1", - user_id="user-1", - content="hello", - ) - - def test_update_reply_raises_forbidden(self, mock_session: Mock) -> None: - reply = Mock() - reply.created_by = "owner" - mock_session.scalar.return_value = reply - - with pytest.raises(Forbidden): - WorkflowCommentService.update_reply( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - reply_id="reply-1", - user_id="intruder", - content="hello", - ) - - def test_update_reply_replaces_mentions(self, mock_session: Mock) -> None: - reply = Mock() - reply.id = "reply-1" - reply.comment_id = "comment-1" - reply.created_by = "owner" - reply.updated_at = "updated" - mock_session.scalar.return_value = reply - mock_session.scalars.return_value = _mock_scalars([Mock()]) - comment = Mock() - comment.tenant_id = "tenant-1" - comment.app_id = "app-1" - mock_session.get.return_value = comment - - with patch.object(WorkflowCommentService, "_filter_valid_mentioned_user_ids", return_value=["user-2"]): - result = WorkflowCommentService.update_reply( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - reply_id="reply-1", - user_id="owner", - content="new", - mentioned_user_ids=["user-2", "bad-id"], - ) - - assert result == {"id": "reply-1", "updated_at": "updated"} - assert mock_session.delete.call_count == 1 - assert mock_session.add.call_count == 1 - mock_session.commit.assert_called_once() - mock_session.refresh.assert_called_once_with(reply) - - def test_update_comment_updates_position_coordinates_when_provided(self, mock_session: Mock) -> None: - comment = Mock() - comment.id = "comment-1" - comment.created_by = "owner" - comment.position_x = 1.0 - comment.position_y = 2.0 - mock_session.scalar.return_value = comment - mock_session.scalars.return_value = _mock_scalars([]) + def test_update_comment_preserves_mentions_when_mentioned_user_ids_omitted( + self, sqlite_session: Session, delay_mock: Mock + ) -> None: + comment = _comment() + _persist(sqlite_session, comment) + mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_2_ID) + _persist(sqlite_session, mention) WorkflowCommentService.update_comment( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - user_id="owner", + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + user_id=OWNER_ID, + content="updated", + ) + + sqlite_session.expire_all() + persisted_comment = sqlite_session.get(WorkflowComment, comment.id) + assert persisted_comment is not None + assert persisted_comment.content == "updated" + assert sqlite_session.get(WorkflowCommentMention, mention.id) is not None + delay_mock.assert_not_called() + + def test_update_comment_clears_mentions_when_empty_list_provided(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + _persist( + sqlite_session, + WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_2_ID), + WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_3_ID), + ) + + WorkflowCommentService.update_comment( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + user_id=OWNER_ID, + content="updated", + mentioned_user_ids=[], + ) + + mention_count = sqlite_session.scalar( + select(func.count()) + .select_from(WorkflowCommentMention) + .where(WorkflowCommentMention.comment_id == comment.id) + ) + assert mention_count == 0 + + def test_update_comment_notifies_only_new_mentions(self, sqlite_session: Session, delay_mock: Mock) -> None: + comment = _comment() + _persist( + sqlite_session, + _app(), + _account(OWNER_ID, name="Owner", email="owner@example.com"), + _account(USER_2_ID, name="Existing", email="existing@example.com"), + _account(USER_3_ID, name="New User", email="new@example.com"), + _membership(USER_2_ID), + _membership(USER_3_ID), + comment, + ) + _persist(sqlite_session, WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_2_ID)) + + WorkflowCommentService.update_comment( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + user_id=OWNER_ID, + content="updated", + mentioned_user_ids=[USER_2_ID, USER_3_ID], + ) + + delay_mock.assert_called_once() + assert delay_mock.call_args.kwargs["to"] == "new@example.com" + mentions = sqlite_session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.comment_id == comment.id) + ).all() + assert {mention.mentioned_user_id for mention in mentions} == {USER_2_ID, USER_3_ID} + + def test_get_comments_preloads_related_accounts(self, sqlite_session: Session) -> None: + comment = _comment(resolved=True, resolved_by=USER_2_ID) + _persist( + sqlite_session, + _account(OWNER_ID, name="Owner"), + _account(USER_2_ID, name="Resolver"), + _account(USER_3_ID, name="Replier"), + _account(USER_4_ID, name="Mentioned"), + comment, + ) + reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by=USER_3_ID) + mention = WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_4_ID) + _persist(sqlite_session, reply, mention) + + result = WorkflowCommentService.get_comments(TENANT_ID, APP_ID) + + assert len(result) == 1 + loaded_comment = result[0] + assert loaded_comment.id == comment.id + assert loaded_comment.created_by_account.id == OWNER_ID + assert loaded_comment.resolved_by_account.id == USER_2_ID + assert loaded_comment.replies[0].created_by_account.id == USER_3_ID + assert loaded_comment.mentions[0].mentioned_user_account.id == USER_4_ID + + def test_preload_accounts_returns_early_for_empty_comments(self, sqlite_session: Session) -> None: + statements: list[str] = [] + bind = sqlite_session.get_bind() + + def record_sql(*args: object) -> None: + statements.append(str(args[2])) + + event.listen(bind, "before_cursor_execute", record_sql) + try: + WorkflowCommentService._preload_accounts(sqlite_session, []) + finally: + event.remove(bind, "before_cursor_execute", record_sql) + + assert statements == [] + + def test_get_comment_raises_not_found_with_provided_session(self, sqlite_session: Session) -> None: + with pytest.raises(NotFound): + WorkflowCommentService.get_comment(TENANT_ID, APP_ID, "missing-comment", session=sqlite_session) + + def test_get_comment_uses_context_manager_when_session_not_provided(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, _account(OWNER_ID, name="Owner"), comment) + + result = WorkflowCommentService.get_comment(TENANT_ID, APP_ID, comment.id) + + assert result.id == comment.id + assert result.created_by_account.id == OWNER_ID + assert result.resolved_by_account is None + + def test_delete_comment_raises_forbidden(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + + with pytest.raises(Forbidden): + WorkflowCommentService.delete_comment(TENANT_ID, APP_ID, comment.id, OUTSIDER_ID) + + assert sqlite_session.get(WorkflowComment, comment.id) is not None + + def test_delete_comment_removes_related_entities(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + comment_id = comment.id + reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by=USER_2_ID) + _persist(sqlite_session, reply) + _persist( + sqlite_session, + WorkflowCommentMention(comment_id=comment.id, mentioned_user_id=USER_3_ID), + WorkflowCommentMention(comment_id=comment.id, reply_id=reply.id, mentioned_user_id=USER_4_ID), + ) + + WorkflowCommentService.delete_comment(TENANT_ID, APP_ID, comment_id, OWNER_ID) + + sqlite_session.expire_all() + assert sqlite_session.get(WorkflowComment, comment_id) is None + assert ( + sqlite_session.scalar( + select(func.count()) + .select_from(WorkflowCommentReply) + .where(WorkflowCommentReply.comment_id == comment_id) + ) + == 0 + ) + assert ( + sqlite_session.scalar( + select(func.count()) + .select_from(WorkflowCommentMention) + .where(WorkflowCommentMention.comment_id == comment_id) + ) + == 0 + ) + + def test_resolve_comment_sets_fields(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + now = datetime(2026, 7, 10, 12, 0, 0) + + with patch.object(service_module, "naive_utc_now", return_value=now): + result = WorkflowCommentService.resolve_comment(TENANT_ID, APP_ID, comment.id, USER_2_ID) + + assert result.resolved is True + assert result.resolved_at == now + assert result.resolved_by == USER_2_ID + sqlite_session.expire_all() + persisted_comment = sqlite_session.get(WorkflowComment, comment.id) + assert persisted_comment is not None + assert persisted_comment.resolved is True + assert persisted_comment.resolved_at == now + assert persisted_comment.resolved_by == USER_2_ID + + def test_resolve_comment_noop_when_already_resolved(self, sqlite_session: Session) -> None: + resolved_at = datetime(2026, 7, 9, 12, 0, 0) + comment = _comment(resolved=True, resolved_at=resolved_at, resolved_by=USER_2_ID) + _persist(sqlite_session, comment) + + result = WorkflowCommentService.resolve_comment(TENANT_ID, APP_ID, comment.id, USER_3_ID) + + assert result.resolved_at == resolved_at + assert result.resolved_by == USER_2_ID + + def test_create_reply_requires_comment(self, sqlite_session: Session) -> None: + with pytest.raises(NotFound): + WorkflowCommentService.create_reply("missing-comment", "hello", OWNER_ID) + + def test_create_reply_creates_mentions(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment, _membership(USER_2_ID)) + + result = WorkflowCommentService.create_reply( + comment_id=comment.id, + content="hello", + created_by=OWNER_ID, + mentioned_user_ids=[USER_2_ID, OUTSIDER_ID], + ) + + reply = sqlite_session.get(WorkflowCommentReply, result["id"]) + assert reply is not None + assert reply.content == "hello" + assert reply.created_at == result["created_at"] + mentions = sqlite_session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply.id) + ).all() + assert [mention.mentioned_user_id for mention in mentions] == [USER_2_ID] + + def test_update_reply_raises_not_found(self, sqlite_session: Session) -> None: + with pytest.raises(NotFound): + WorkflowCommentService.update_reply( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id="missing-comment", + reply_id="missing-reply", + user_id=OWNER_ID, + content="hello", + ) + + def test_update_reply_raises_forbidden(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by=OWNER_ID) + _persist(sqlite_session, reply) + + with pytest.raises(Forbidden): + WorkflowCommentService.update_reply( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + reply_id=reply.id, + user_id=OUTSIDER_ID, + content="hello", + ) + + def test_update_reply_replaces_mentions(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment, _membership(USER_2_ID)) + reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by=OWNER_ID) + _persist(sqlite_session, reply) + _persist( + sqlite_session, + WorkflowCommentMention(comment_id=comment.id, reply_id=reply.id, mentioned_user_id=USER_3_ID), + ) + + result = WorkflowCommentService.update_reply( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + reply_id=reply.id, + user_id=OWNER_ID, + content="new", + mentioned_user_ids=[USER_2_ID, OUTSIDER_ID], + ) + + sqlite_session.expire_all() + persisted_reply = sqlite_session.get(WorkflowCommentReply, reply.id) + assert persisted_reply is not None + assert persisted_reply.content == "new" + assert result == {"id": reply.id, "updated_at": persisted_reply.updated_at} + mentions = sqlite_session.scalars( + select(WorkflowCommentMention).where(WorkflowCommentMention.reply_id == reply.id) + ).all() + assert [mention.mentioned_user_id for mention in mentions] == [USER_2_ID] + + def test_update_comment_updates_position_coordinates_when_provided(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + + WorkflowCommentService.update_comment( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + user_id=OWNER_ID, content="updated", position_x=10.5, position_y=20.5, mentioned_user_ids=[], ) - assert comment.position_x == 10.5 - assert comment.position_y == 20.5 + sqlite_session.expire_all() + persisted_comment = sqlite_session.get(WorkflowComment, comment.id) + assert persisted_comment is not None + assert persisted_comment.position_x == 10.5 + assert persisted_comment.position_y == 20.5 - def test_delete_reply_raises_forbidden(self, mock_session: Mock) -> None: - reply = Mock() - reply.created_by = "owner" - mock_session.scalar.return_value = reply + def test_delete_reply_raises_forbidden(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by=OWNER_ID) + _persist(sqlite_session, reply) with pytest.raises(Forbidden): WorkflowCommentService.delete_reply( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - reply_id="reply-1", - user_id="intruder", + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + reply_id=reply.id, + user_id=OUTSIDER_ID, ) - def test_delete_reply_raises_not_found(self, mock_session: Mock) -> None: - mock_session.scalar.return_value = None - + def test_delete_reply_raises_not_found(self, sqlite_session: Session) -> None: with pytest.raises(NotFound): WorkflowCommentService.delete_reply( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - reply_id="reply-1", - user_id="owner", + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id="missing-comment", + reply_id="missing-reply", + user_id=OWNER_ID, ) - def test_delete_reply_removes_mentions(self, mock_session: Mock) -> None: - reply = Mock() - reply.created_by = "owner" - mock_session.scalar.return_value = reply - mock_session.scalars.return_value = _mock_scalars([Mock(), Mock()]) - - WorkflowCommentService.delete_reply( - tenant_id="tenant-1", - app_id="app-1", - comment_id="comment-1", - reply_id="reply-1", - user_id="owner", + def test_delete_reply_removes_mentions(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by=OWNER_ID) + _persist(sqlite_session, reply) + reply_id = reply.id + _persist( + sqlite_session, + WorkflowCommentMention(comment_id=comment.id, reply_id=reply.id, mentioned_user_id=USER_2_ID), + WorkflowCommentMention(comment_id=comment.id, reply_id=reply.id, mentioned_user_id=USER_3_ID), ) - assert mock_session.delete.call_count == 3 - mock_session.commit.assert_called_once() + WorkflowCommentService.delete_reply( + tenant_id=TENANT_ID, + app_id=APP_ID, + comment_id=comment.id, + reply_id=reply_id, + user_id=OWNER_ID, + ) - def test_validate_comment_access_delegates_to_get_comment(self) -> None: - comment = Mock() - with patch.object(WorkflowCommentService, "get_comment", return_value=comment) as get_comment_mock: - result = WorkflowCommentService.validate_comment_access("comment-1", "tenant-1", "app-1") + sqlite_session.expire_all() + assert sqlite_session.get(WorkflowCommentReply, reply_id) is None + assert ( + sqlite_session.scalar( + select(func.count()) + .select_from(WorkflowCommentMention) + .where(WorkflowCommentMention.reply_id == reply_id) + ) + == 0 + ) - assert result is comment - get_comment_mock.assert_called_once_with("tenant-1", "app-1", "comment-1") + def test_validate_comment_access_delegates_to_get_comment(self, sqlite_session: Session) -> None: + comment = _comment() + _persist(sqlite_session, comment) + + result = WorkflowCommentService.validate_comment_access(comment.id, TENANT_ID, APP_ID) + + assert result.id == comment.id + + def test_reply_lookup_is_scoped_to_tenant_app_and_comment(self, sqlite_session: Session) -> None: + comment = _comment() + other_comment = _comment(app_id=OTHER_APP_ID) + _persist(sqlite_session, comment, other_comment) + reply = WorkflowCommentReply(comment_id=comment.id, content="reply", created_by=OWNER_ID) + _persist(sqlite_session, reply) + + with pytest.raises(NotFound): + WorkflowCommentService.update_reply( + tenant_id=TENANT_ID, + app_id=OTHER_APP_ID, + comment_id=other_comment.id, + reply_id=reply.id, + user_id=OWNER_ID, + content="cross-thread update", + ) + + sqlite_session.refresh(reply) + assert reply.content == "reply" From 94702efbc2d86be4238e0a99a1683b6c7351ed7f Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:23:15 -0700 Subject: [PATCH 041/531] fix: gate service API, MCP and trigger surfaces on enterprise license (#39635) --- api/app_factory.py | 89 ++++--- api/tests/unit_tests/test_app_factory.py | 314 +++++++++++++++++++++++ 2 files changed, 373 insertions(+), 30 deletions(-) create mode 100644 api/tests/unit_tests/test_app_factory.py diff --git a/api/app_factory.py b/api/app_factory.py index 2cea8cfb3f7..c40635a32e0 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -1,10 +1,13 @@ import logging import time +from collections.abc import Callable +from typing import NamedTuple import socketio from flask import request from opentelemetry.trace import get_current_span from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID +from werkzeug.exceptions import Forbidden, HTTPException, ServiceUnavailable from configs import dify_config from contexts.wrapper import RecyclableContextVar @@ -42,6 +45,53 @@ _CONSOLE_EXEMPT_PREFIXES = ( "/console/api/activate/check", ) +_WEBAPP_EXEMPT_PREFIXES = ("/api/system-features",) + +_INVALID_LICENSE_STATUSES = (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST) + + +def _session_surface_error(license_status: LicenseStatus | None) -> HTTPException: + if license_status is None: + return UnauthorizedAndForceLogout("Unable to verify enterprise license. Please contact your administrator.") + return UnauthorizedAndForceLogout(f"Enterprise license is {license_status}. Please contact your administrator.") + + +def _bearer_surface_error(license_status: LicenseStatus | None) -> HTTPException: + """Token-authed: forcing a logout is meaningless and license state must not leak.""" + return Forbidden(description="license_required") + + +def _retryable_surface_error(license_status: LicenseStatus | None) -> HTTPException: + """Webhook senders retry on 5xx but treat 4xx as permanent, disabling the subscription.""" + return ServiceUnavailable(description="license_required") + + +class _LicenseGatedSurface(NamedTuple): + prefix: str + exempt_prefixes: tuple[str, ...] + build_error: Callable[[LicenseStatus | None], HTTPException] + + +# /files (plugin-daemon data plane), /inner/api (enterprise control plane) and /health +# stay ungated: blocking them breaks workflow execution or license recovery itself. +_LICENSE_GATED_SURFACES = ( + _LicenseGatedSurface("/console/api/", _CONSOLE_EXEMPT_PREFIXES, _session_surface_error), + _LicenseGatedSurface("/api/", _WEBAPP_EXEMPT_PREFIXES, _session_surface_error), + _LicenseGatedSurface("/v1", (), _bearer_surface_error), + _LicenseGatedSurface("/mcp", (), _bearer_surface_error), + _LicenseGatedSurface("/triggers", (), _retryable_surface_error), +) + + +def _match_license_gated_surface(path: str) -> _LicenseGatedSurface | None: + for surface in _LICENSE_GATED_SURFACES: + if not path.startswith(surface.prefix): + continue + if any(path.startswith(exempt) for exempt in surface.exempt_prefixes): + return None + return surface + return None + # ---------------------------- # Application Factory Function @@ -62,38 +112,17 @@ def create_flask_app_with_configs() -> DifyApp: init_request_context() RecyclableContextVar.increment_thread_recycles() - # Enterprise license validation for API endpoints (both console and webapp) - # When license expires, block all API access except bootstrap endpoints needed - # for the frontend to load the license expiration page without infinite reloads. if dify_config.ENTERPRISE_ENABLED: - is_console_api = request.path.startswith("/console/api/") - is_webapp_api = request.path.startswith("/api/") + surface = _match_license_gated_surface(request.path) + if surface is not None: + try: + license_status = EnterpriseService.get_cached_license_status() + except Exception: + logger.exception("Failed to check enterprise license status") + license_status = None - if is_console_api or is_webapp_api: - if is_console_api: - is_exempt = any(request.path.startswith(p) for p in _CONSOLE_EXEMPT_PREFIXES) - else: # webapp API - is_exempt = request.path.startswith("/api/system-features") - - if not is_exempt: - try: - # Check license status (cached — see EnterpriseService for TTL details) - license_status = EnterpriseService.get_cached_license_status() - if license_status in (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST): - raise UnauthorizedAndForceLogout( - f"Enterprise license is {license_status}. Please contact your administrator." - ) - if license_status is None: - raise UnauthorizedAndForceLogout( - "Unable to verify enterprise license. Please contact your administrator." - ) - except UnauthorizedAndForceLogout: - raise - except Exception: - logger.exception("Failed to check enterprise license status") - raise UnauthorizedAndForceLogout( - "Unable to verify enterprise license. Please contact your administrator." - ) + if license_status is None or license_status in _INVALID_LICENSE_STATUSES: + raise surface.build_error(license_status) # add after request hook for injecting trace headers from OpenTelemetry span context # Only adds headers when OTEL is enabled and has valid context diff --git a/api/tests/unit_tests/test_app_factory.py b/api/tests/unit_tests/test_app_factory.py new file mode 100644 index 00000000000..9c905f5b101 --- /dev/null +++ b/api/tests/unit_tests/test_app_factory.py @@ -0,0 +1,314 @@ +"""Enterprise license gating performed by the global ``before_request`` hook.""" + +from unittest.mock import patch + +import pytest +from flask import Blueprint, Flask +from flask_restx import Resource + +from app_factory import create_flask_app_with_configs +from libs.external_api import ExternalApi +from services.feature_service import LicenseStatus + +INVALID_STATUSES = [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST] +VALID_STATUSES = [LicenseStatus.ACTIVE, LicenseStatus.EXPIRING] + + +def _license(status: LicenseStatus | None): + return patch("app_factory.EnterpriseService.get_cached_license_status", return_value=status) + + +def _enterprise(enabled: bool = True): + return patch("app_factory.dify_config.ENTERPRISE_ENABLED", enabled) + + +@pytest.fixture +def gated_app() -> Flask: + app = create_flask_app_with_configs() + + @app.route("/v1/chat-messages", methods=["POST"]) + def service_api_route(): + return {"surface": "service_api"} + + @app.route("/v1/") + def service_api_index_route(): + return {"surface": "service_api_index"} + + @app.route("/mcp/server//mcp", methods=["POST"]) + def mcp_route(server_code: str): + return {"surface": "mcp"} + + @app.route("/triggers/webhook/", methods=["POST"]) + def trigger_route(webhook_id: str): + return {"surface": "triggers"} + + @app.route("/console/api/apps") + def console_route(): + return {"surface": "console"} + + @app.route("/console/api/login", methods=["POST"]) + def console_bootstrap_route(): + return {"surface": "console_bootstrap"} + + @app.route("/api/messages") + def webapp_route(): + return {"surface": "webapp"} + + @app.route("/api/system-features") + def webapp_bootstrap_route(): + return {"surface": "webapp_bootstrap"} + + @app.route("/health") + def health_route(): + return {"surface": "health"} + + @app.route("/inner/api/rbac/check-access", methods=["POST"]) + def inner_api_route(): + return {"surface": "inner_api"} + + @app.route("/files/upload/for-plugin", methods=["POST"]) + def files_route(): + return {"surface": "files"} + + return app + + +class TestServiceApiLicenseGate: + """/v1 is a bearer-token surface, so it is gated with an opaque 403.""" + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_blocks_when_license_invalid(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().post("/v1/chat-messages") + + assert response.status_code == 403 + + def test_block_response_carries_machine_readable_marker(self, gated_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().post("/v1/chat-messages") + + assert b"license_required" in response.data + + def test_block_response_does_not_leak_license_status(self, gated_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().post("/v1/chat-messages") + + assert b"expired" not in response.data.lower() + + def test_blocks_when_license_status_unavailable(self, gated_app: Flask): + with _enterprise(), _license(None): + response = gated_app.test_client().post("/v1/chat-messages") + + assert response.status_code == 403 + + def test_blocks_when_license_lookup_raises(self, gated_app: Flask): + lookup_failed = patch( + "app_factory.EnterpriseService.get_cached_license_status", + side_effect=RuntimeError("enterprise api unreachable"), + ) + with _enterprise(), lookup_failed: + response = gated_app.test_client().post("/v1/chat-messages") + + assert response.status_code == 403 + + def test_blocks_index_route(self, gated_app: Flask): + """/v1 has no sign-in page to bootstrap, so nothing on it is exempt.""" + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().get("/v1/") + + assert response.status_code == 403 + + @pytest.mark.parametrize("status", VALID_STATUSES) + def test_allows_when_license_valid(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().post("/v1/chat-messages") + + assert response.status_code == 200 + + def test_allows_unclassified_status(self, gated_app: Flask): + """LicenseStatus.NONE is not in the blocked set — parity with console/webapp.""" + with _enterprise(), _license(LicenseStatus.NONE): + response = gated_app.test_client().post("/v1/chat-messages") + + assert response.status_code == 200 + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(False), _license(status): + response = gated_app.test_client().post("/v1/chat-messages") + + assert response.status_code == 200 + + +class TestMcpLicenseGate: + """/mcp invokes apps for external MCP clients, so it is gated like the Service API.""" + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_blocks_when_license_invalid(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().post("/mcp/server/srv-code/mcp") + + assert response.status_code == 403 + + def test_block_response_carries_machine_readable_marker(self, gated_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().post("/mcp/server/srv-code/mcp") + + assert b"license_required" in response.data + + @pytest.mark.parametrize("status", VALID_STATUSES) + def test_allows_when_license_valid(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().post("/mcp/server/srv-code/mcp") + + assert response.status_code == 200 + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(False), _license(status): + response = gated_app.test_client().post("/mcp/server/srv-code/mcp") + + assert response.status_code == 200 + + +class TestTriggerLicenseGate: + """Inbound webhooks are refused so senders retry, rather than dropping events.""" + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_blocks_when_license_invalid(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().post("/triggers/webhook/hook-id") + + assert response.status_code == 503 + + def test_block_response_carries_machine_readable_marker(self, gated_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().post("/triggers/webhook/hook-id") + + assert b"license_required" in response.data + + @pytest.mark.parametrize("status", VALID_STATUSES) + def test_allows_when_license_valid(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().post("/triggers/webhook/hook-id") + + assert response.status_code == 200 + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(False), _license(status): + response = gated_app.test_client().post("/triggers/webhook/hook-id") + + assert response.status_code == 200 + + +class TestGateThroughRealErrorHandlers: + """Gate errors must survive each blueprint's error handling: flask-restx vs plain Flask.""" + + @pytest.fixture + def wired_app(self) -> Flask: + app = create_flask_app_with_configs() + + service_api_bp = Blueprint("service_api_test", __name__, url_prefix="/v1") + api = ExternalApi(service_api_bp) + + @api.route("/chat-messages") + class ChatMessages(Resource): + def post(self): + return {"surface": "service_api"} + + app.register_blueprint(service_api_bp) + + trigger_bp = Blueprint("trigger_test", __name__, url_prefix="/triggers") + + @trigger_bp.route("/webhook/", methods=["POST"]) + def webhook_route(webhook_id: str): + return {"surface": "triggers"} + + app.register_blueprint(trigger_bp) + return app + + def test_service_api_block_is_json_with_license_marker(self, wired_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = wired_app.test_client().post("/v1/chat-messages") + + assert response.status_code == 403 + body = response.get_json() + assert body["message"] == "license_required" + assert body["status"] == 403 + + def test_service_api_block_does_not_clear_cookies(self, wired_app: Flask): + """Force-logout cookie clearing belongs to the cookie-authed surfaces only.""" + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = wired_app.test_client().post("/v1/chat-messages") + + assert response.headers.getlist("Set-Cookie") == [] + + def test_trigger_block_survives_plain_blueprint_handling(self, wired_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = wired_app.test_client().post("/triggers/webhook/hook-id") + + assert response.status_code == 503 + assert b"license_required" in response.data + + def test_surfaces_are_reachable_when_license_valid(self, wired_app: Flask): + with _enterprise(), _license(LicenseStatus.ACTIVE): + service_api = wired_app.test_client().post("/v1/chat-messages") + triggers = wired_app.test_client().post("/triggers/webhook/hook-id") + + assert service_api.status_code == 200 + assert triggers.status_code == 200 + + +class TestUngatedSurfaces: + """Surfaces that must stay reachable while the license is invalid.""" + + def test_inner_api_is_not_gated(self, gated_app: Flask): + """dify-enterprise control plane — gating it could block license recovery itself.""" + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().post("/inner/api/rbac/check-access") + + assert response.status_code == 200 + + def test_files_data_plane_is_not_gated(self, gated_app: Flask): + """Signed file URLs are fetched by the plugin daemon and by LLM vendors.""" + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().post("/files/upload/for-plugin") + + assert response.status_code == 200 + + +class TestSessionSurfaceLicenseGate: + """Console and webapp are cookie-authed, so they keep force-logout 401 semantics.""" + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_blocks_console_with_force_logout(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().get("/console/api/apps") + + assert response.status_code == 401 + + @pytest.mark.parametrize("status", INVALID_STATUSES) + def test_blocks_webapp_with_force_logout(self, gated_app: Flask, status: LicenseStatus): + with _enterprise(), _license(status): + response = gated_app.test_client().get("/api/messages") + + assert response.status_code == 401 + + def test_console_bootstrap_route_stays_reachable(self, gated_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().post("/console/api/login") + + assert response.status_code == 200 + + def test_webapp_bootstrap_route_stays_reachable(self, gated_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().get("/api/system-features") + + assert response.status_code == 200 + + def test_health_route_is_never_gated(self, gated_app: Flask): + with _enterprise(), _license(LicenseStatus.EXPIRED): + response = gated_app.test_client().get("/health") + + assert response.status_code == 200 From 3c80857ea34e21f3800b17d864623658bdb9cea0 Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Mon, 27 Jul 2026 16:37:30 +0800 Subject: [PATCH 042/531] feat: add bearer auth to agent backend (#39622) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/.env.example | 2 + api/clients/agent_backend/factory.py | 6 +- api/configs/extra/agent_backend_config.py | 5 ++ api/core/app/apps/agent_app/app_generator.py | 1 + api/core/workflow/node_factory.py | 1 + .../agent_backend_session_cleanup_task.py | 1 + .../configs/test_env_consistency.py | 2 + dify-agent/.example.env | 6 ++ dify-agent/src/dify_agent/server/app.py | 9 ++- dify-agent/src/dify_agent/server/auth.py | 43 ++++++++++++++ .../src/dify_agent/server/routes/runs.py | 7 ++- dify-agent/src/dify_agent/server/settings.py | 1 + .../local/dify_agent/server/test_auth.py | 57 +++++++++++++++++++ docker/.env.example | 4 ++ docker/docker-compose-template.yaml | 3 + docker/docker-compose.yaml | 3 + 16 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 dify-agent/src/dify_agent/server/auth.py create mode 100644 dify-agent/tests/local/dify_agent/server/test_auth.py diff --git a/api/.env.example b/api/.env.example index 3e600365806..d9fed2d9318 100644 --- a/api/.env.example +++ b/api/.env.example @@ -677,6 +677,8 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y # Dify Agent backend AGENT_BACKEND_BASE_URL=http://localhost:5050 +# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side. +AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30 AGENT_BACKEND_STREAM_MAX_RECONNECTS=3 AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200 diff --git a/api/clients/agent_backend/factory.py b/api/clients/agent_backend/factory.py index 0fcbf02bf70..2fd9c6faf4a 100644 --- a/api/clients/agent_backend/factory.py +++ b/api/clients/agent_backend/factory.py @@ -11,6 +11,7 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge def create_agent_backend_run_client( *, base_url: str | None = None, + api_token: str | None = None, use_fake: bool = False, fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS, stream_read_timeout_seconds: float = 30, @@ -22,8 +23,11 @@ def create_agent_backend_run_client( return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario)) if base_url is None: raise ValueError("base_url is required when creating a real Agent backend client") + headers: dict[str, str] = {} + if api_token: + headers["Authorization"] = f"Bearer {api_token}" return DifyAgentBackendRunClient( - Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds), + Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers), stream_max_reconnects=stream_max_reconnects, stream_timeout_seconds=stream_run_timeout_seconds, ) diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 7baad3d0b44..d5caf3d2e3c 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -12,6 +12,11 @@ class AgentBackendConfig(BaseSettings): default=None, ) + AGENT_BACKEND_API_TOKEN: str | None = Field( + description="Bearer token for authenticating with the Agent backend /runs API.", + default=None, + ) + AGENT_BACKEND_USE_FAKE: bool = Field( description="Use the deterministic in-process fake Agent backend client.", default=False, diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 26e5d0cdcb6..2f343202f4b 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -538,6 +538,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider), agent_backend_client=create_agent_backend_run_client( base_url=dify_config.AGENT_BACKEND_BASE_URL, + api_token=dify_config.AGENT_BACKEND_API_TOKEN, use_fake=dify_config.AGENT_BACKEND_USE_FAKE, fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS, diff --git a/api/core/workflow/node_factory.py b/api/core/workflow/node_factory.py index 3b47e32adf1..02fdb379a45 100644 --- a/api/core/workflow/node_factory.py +++ b/api/core/workflow/node_factory.py @@ -497,6 +497,7 @@ class DifyNodeFactory(NodeFactory): ), "agent_backend_client": create_agent_backend_run_client( base_url=dify_config.AGENT_BACKEND_BASE_URL, + api_token=dify_config.AGENT_BACKEND_API_TOKEN, use_fake=dify_config.AGENT_BACKEND_USE_FAKE, fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS, diff --git a/api/tasks/agent_backend_session_cleanup_task.py b/api/tasks/agent_backend_session_cleanup_task.py index f1316266db7..0dbc32b45e6 100644 --- a/api/tasks/agent_backend_session_cleanup_task.py +++ b/api/tasks/agent_backend_session_cleanup_task.py @@ -22,6 +22,7 @@ def _create_agent_backend_client(): return None return create_agent_backend_run_client( base_url=dify_config.AGENT_BACKEND_BASE_URL, + api_token=dify_config.AGENT_BACKEND_API_TOKEN, use_fake=dify_config.AGENT_BACKEND_USE_FAKE, fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS, diff --git a/api/tests/unit_tests/configs/test_env_consistency.py b/api/tests/unit_tests/configs/test_env_consistency.py index 81e08638145..1afdd307b8c 100644 --- a/api/tests/unit_tests/configs/test_env_consistency.py +++ b/api/tests/unit_tests/configs/test_env_consistency.py @@ -4,6 +4,7 @@ from dotenv import dotenv_values BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset( ( + "AGENT_BACKEND_API_TOKEN", "APP_MAX_EXECUTION_TIME", "BATCH_UPLOAD_LIMIT", "CELERY_BEAT_SCHEDULER_TIME", @@ -43,6 +44,7 @@ BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset( BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset( ( + "AGENT_BACKEND_API_TOKEN", "BATCH_UPLOAD_LIMIT", "CELERY_BEAT_SCHEDULER_TIME", "HTTP_REQUEST_MAX_CONNECT_TIMEOUT", diff --git a/dify-agent/.example.env b/dify-agent/.example.env index 26f25293efd..4f11bb9747d 100644 --- a/dify-agent/.example.env +++ b/dify-agent/.example.env @@ -56,6 +56,12 @@ DIFY_AGENT_STUB_GRPC_BIND_ADDRESS= # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY +# Inbound Bearer token for /runs API authentication. +# Must match AGENT_BACKEND_API_TOKEN on the Dify API side. +# Replace this development default in production. +# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' +DIFY_AGENT_API_TOKEN=dify-agent-run-token-for-dev-only + # Shared plugin-daemon HTTP client timeouts and limits. # Plugin-daemon HTTP connect timeout in seconds. DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT=10 diff --git a/dify-agent/src/dify_agent/server/app.py b/dify-agent/src/dify_agent/server/app.py index 1013cf89bb0..1fadd208d57 100644 --- a/dify-agent/src/dify_agent/server/app.py +++ b/dify-agent/src/dify_agent/server/app.py @@ -29,6 +29,7 @@ from dify_agent.agent_stub.server.router import create_agent_stub_router from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.runtime.compositor_factory import create_default_layer_providers from dify_agent.runtime.run_scheduler import RunScheduler +from dify_agent.server.auth import create_bearer_token_dependency from dify_agent.server.observability import configure_server_observability from dify_agent.server.routes.runs import create_runs_router from dify_agent.server.routes.sandbox_files import create_sandbox_files_router @@ -123,7 +124,13 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI: def get_scheduler() -> RunScheduler: return state["scheduler"] # pyright: ignore[reportReturnType] - app.include_router(create_runs_router(get_store, get_scheduler)) + app.include_router( + create_runs_router( + get_store, + get_scheduler, + auth_dependency=create_bearer_token_dependency(resolved_settings.api_token), + ) + ) app.include_router(create_sandbox_files_router(lambda: sandbox_file_service)) app.include_router( create_agent_stub_router( diff --git a/dify-agent/src/dify_agent/server/auth.py b/dify-agent/src/dify_agent/server/auth.py new file mode 100644 index 00000000000..a991639a3ec --- /dev/null +++ b/dify-agent/src/dify_agent/server/auth.py @@ -0,0 +1,43 @@ +import hmac + +from fastapi import Depends, Header, HTTPException + + +def create_bearer_token_dependency(expected_token: str | None): + """Return a FastAPI dependency that validates Bearer token authentication. + + When ``expected_token`` is ``None``, the returned dependency permits all + requests without checking the header, supporting graceful migration for + existing deployments. + """ + + async def require_bearer_token( + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> None: + if expected_token is None: + return + if authorization is None: + raise HTTPException( + status_code=401, + detail="missing authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + scheme, _, token = authorization.partition(" ") + token = token.strip() + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=401, + detail="invalid authorization scheme", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not hmac.compare_digest(token.encode(), expected_token.encode()): + raise HTTPException( + status_code=401, + detail="invalid bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return Depends(require_bearer_token) + + +__all__ = ["create_bearer_token_dependency"] diff --git a/dify-agent/src/dify_agent/server/routes/runs.py b/dify-agent/src/dify_agent/server/routes/runs.py index f41567648e8..031678789bc 100644 --- a/dify-agent/src/dify_agent/server/routes/runs.py +++ b/dify-agent/src/dify_agent/server/routes/runs.py @@ -14,6 +14,7 @@ from collections.abc import Callable from typing import Annotated from fastapi import APIRouter, Depends, Header, HTTPException, Query +from fastapi.params import Depends as DependsInstance from fastapi.responses import StreamingResponse from dify_agent.protocol.schemas import ( @@ -32,9 +33,11 @@ from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError def create_runs_router( get_store: Callable[[], RedisRunStore], get_scheduler: Callable[[], RunScheduler], + *, + auth_dependency: DependsInstance | None = None, ) -> APIRouter: - """Create routes bound to the application's store dependency provider.""" - router = APIRouter(prefix="/runs", tags=["runs"]) + dependencies: list[DependsInstance] = [auth_dependency] if auth_dependency is not None else [] + router = APIRouter(prefix="/runs", tags=["runs"], dependencies=dependencies) async def store_dep() -> RedisRunStore: return get_store() diff --git a/dify-agent/src/dify_agent/server/settings.py b/dify-agent/src/dify_agent/server/settings.py index d6e8dfbcb4f..c24981d0e73 100644 --- a/dify-agent/src/dify_agent/server/settings.py +++ b/dify-agent/src/dify_agent/server/settings.py @@ -52,6 +52,7 @@ class ServerSettings(BaseSettings): agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL") agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS") server_secret_key: str | None = None + api_token: str | None = None shell_redact_patterns: str = "" outbound_http_connect_timeout: float = Field(default=10.0, ge=0) outbound_http_read_timeout: float = Field(default=600.0, ge=0) diff --git a/dify-agent/tests/local/dify_agent/server/test_auth.py b/dify-agent/tests/local/dify_agent/server/test_auth.py new file mode 100644 index 00000000000..fa561b3bf56 --- /dev/null +++ b/dify-agent/tests/local/dify_agent/server/test_auth.py @@ -0,0 +1,57 @@ +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from dify_agent.server.auth import create_bearer_token_dependency + + +def _build_app(expected_token: str | None) -> FastAPI: + app = FastAPI() + dep = create_bearer_token_dependency(expected_token) + + @app.get("/protected", dependencies=[dep]) + async def protected() -> dict[str, str]: + return {"status": "ok"} + + return app + + +class TestBearerTokenAuthEnabled: + """Auth is enforced when a non-None token is configured.""" + + def test_missing_header_returns_401(self) -> None: + client = TestClient(_build_app("secret-token")) + response = client.get("/protected") + assert response.status_code == 401 + assert "missing" in response.json()["detail"] + + def test_invalid_scheme_returns_401(self) -> None: + client = TestClient(_build_app("secret-token")) + response = client.get("/protected", headers={"Authorization": "Basic abc"}) + assert response.status_code == 401 + assert "scheme" in response.json()["detail"] + + def test_wrong_token_returns_401(self) -> None: + client = TestClient(_build_app("secret-token")) + response = client.get("/protected", headers={"Authorization": "Bearer wrong"}) + assert response.status_code == 401 + assert "invalid bearer token" in response.json()["detail"] + + def test_correct_token_passes(self) -> None: + client = TestClient(_build_app("secret-token")) + response = client.get("/protected", headers={"Authorization": "Bearer secret-token"}) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +class TestBearerTokenAuthDisabled: + """Auth is a no-op when expected_token is None (backward compatibility).""" + + def test_no_header_passes_when_token_unconfigured(self) -> None: + client = TestClient(_build_app(None)) + response = client.get("/protected") + assert response.status_code == 200 + + def test_any_header_passes_when_token_unconfigured(self) -> None: + client = TestClient(_build_app(None)) + response = client.get("/protected", headers={"Authorization": "Bearer anything"}) + assert response.status_code == 200 diff --git a/docker/.env.example b/docker/.env.example index 3b3de9cd976..0bc034d004b 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -253,6 +253,10 @@ MARKETPLACE_URL= # Dify Agent backend AGENT_BACKEND_BASE_URL=http://agent_backend:5050 +# Bearer token for the Agent backend /runs API. +# Replace this development default in production. +# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' +DIFY_AGENT_API_TOKEN=dify-agent-run-token-for-dev-only AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30 AGENT_BACKEND_STREAM_MAX_RECONNECTS=3 AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200 diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 7049e237ba4..016177008b0 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -232,6 +232,7 @@ services: PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0} INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1} AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050} + AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only} AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30} AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3} AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200} @@ -305,6 +306,7 @@ services: PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800} INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1} AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050} + AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only} AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30} AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3} AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200} @@ -673,6 +675,7 @@ services: # Replace this development default in production. # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY} + DIFY_AGENT_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only} DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30} DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200} depends_on: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 5eae7d4d3ca..e95fce741b7 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -238,6 +238,7 @@ services: PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0} INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1} AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050} + AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only} AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30} AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3} AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200} @@ -311,6 +312,7 @@ services: PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800} INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1} AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050} + AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only} AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30} AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3} AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200} @@ -679,6 +681,7 @@ services: # Replace this development default in production. # Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))' DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY} + DIFY_AGENT_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only} DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30} DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200} depends_on: From 481354ca70649cdfcb1c8a853daf5a5a7d4414ff Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 27 Jul 2026 16:43:45 +0800 Subject: [PATCH 043/531] refactor: make agent composer save and publish state consistent (#39637) --- .../nodes/agent-v2/__tests__/hooks.spec.tsx | 9 +- .../nodes/agent-v2/agent-soul-config.ts | 14 +- .../agent-orchestrate-panel-content.spec.tsx | 1 - .../agent-orchestrate-panel-content.tsx | 14 +- .../__tests__/provider.spec.tsx | 40 +- .../agent-composer/__tests__/store.spec.ts | 17 +- .../agent-v2/agent-composer/provider.tsx | 14 +- web/features/agent-v2/agent-composer/store.ts | 24 +- .../configure/__tests__/page.spec.tsx | 49 +++ .../use-agent-configure-sync.spec.tsx | 382 +++++++++++++++--- .../configure/components/composer-session.tsx | 18 +- .../__tests__/publish-bar.spec.tsx | 199 ++++++--- .../files/__tests__/index.spec.tsx | 8 +- .../components/orchestrate/index.tsx | 12 - .../orchestrate/publish-bar/index.tsx | 95 +++-- .../tools/__tests__/index.spec.tsx | 6 +- .../configure/use-agent-configure-sync.ts | 271 ++++++++----- web/service/client.spec.ts | 74 +++- web/service/client.ts | 45 ++- 19 files changed, 865 insertions(+), 427 deletions(-) diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx index 95f5cb78bea..720c6cef1dd 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx @@ -4,8 +4,7 @@ import { getDefaultStore } from 'jotai' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, + agentComposerSavedDraftAtom, } from '@/features/agent-v2/agent-composer/store' import { FlowType } from '@/types/common' import { renderWorkflowHook } from '../../../__tests__/workflow-test-env' @@ -591,13 +590,11 @@ describe('useWorkflowInlineAgentConfigureSync', () => { beforeEach(() => { vi.clearAllMocks() const store = getDefaultStore() - store.set(agentComposerOriginalConfigAtom, undefined) - store.set(agentComposerOriginalDraftAtom, defaultAgentSoulConfigFormState) + store.set(agentComposerSavedDraftAtom, defaultAgentSoulConfigFormState) store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState) }) it('saves inline agent composer changes through the workflow node composer API', async () => { - vi.setSystemTime(1710000300000) const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -667,7 +664,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => { }, expect.any(Object), ) - await waitFor(() => expect(result.current.draftSavedAt).toBe(1710000300000)) expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual( expect.objectContaining({ agent_soul: expect.objectContaining({ @@ -851,7 +847,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => { expect(mockComposerMutationFn).not.toHaveBeenCalled() expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toBeUndefined() - expect(result.current.draftSavedAt).toBeUndefined() }) it('saves the effective inline model when the form draft is unchanged', async () => { diff --git a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts index 64fef972ff5..6195dbaca26 100644 --- a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts +++ b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts @@ -7,7 +7,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { debounce } from 'es-toolkit/compat' import isEqual from 'fast-deep-equal' import { useStore as useJotaiStore, useSetAtom } from 'jotai' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { agentSoulConfigToFormState, @@ -15,8 +15,7 @@ import { } from '@/features/agent-v2/agent-composer/conversions' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' import { consoleQuery } from '@/service/client' @@ -77,9 +76,7 @@ export function useWorkflowInlineAgentConfigureSync({ const queryClient = useQueryClient() const configsMap = useHooksStore((state) => state.configsMap) const store = useJotaiStore() - const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom) - const setOriginalDraft = useSetAtom(agentComposerOriginalDraftAtom) - const [draftSavedAt, setDraftSavedAt] = useState(undefined) + const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom) const baseConfigRef = useRef(baseConfig) const currentModelRef = useRef(currentModel) const enabledRef = useRef(enabled) @@ -165,9 +162,7 @@ export function useWorkflowInlineAgentConfigureSync({ composerState, ) } - setOriginalConfig(composerState.agent_soul) - setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul)) - setDraftSavedAt(Date.now()) + setSavedDraft(agentSoulConfigToFormState(composerState.agent_soul)) lastAutosavedDraftKeyRef.current = savedDraftKey onDraftSavedRef.current?.(composerState) return composerState @@ -230,7 +225,6 @@ export function useWorkflowInlineAgentConfigureSync({ }, [autoSaveEnabled, debouncedSaveDraft]) return { - draftSavedAt, saveAgentSoulConfig, saveDraft, } diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index 189112c4e66..ea410bd8635 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -183,7 +183,6 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/preview-c vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({ useWorkflowInlineAgentConfigureSync: () => ({ - draftSavedAt: undefined, saveAgentSoulConfig: mocks.saveAgentSoulConfig, saveDraft: mocks.saveDraft, }), diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx index 25e2b41bd86..0c9c753be86 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx @@ -130,10 +130,8 @@ export function WorkflowRosterAgentOrchestratePanelContent( & { - activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null agentId: string agentSoulConfig: AgentSoulConfig buildDraft: ReturnType @@ -331,7 +322,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ const { currentModel, setConfigureModel, textGenerationModelList } = useAgentOrchestrateModelOptions() const [isApplyingInlineBuildDraft, setIsApplyingInlineBuildDraft] = useState(false) - const { draftSavedAt, saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({ + const { saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({ nodeId, baseConfig: agentSoulConfig, currentModel, @@ -462,7 +453,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ (agentSoulConfig?: AgentSoulConfig) => { rebaseComposerDraft({ draft: agentSoulConfigToFormState(agentSoulConfig), - originalConfig: agentSoulConfig, }) }, [rebaseComposerDraft], @@ -688,12 +678,10 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ agentId={agentId} appId={appId} nodeId={nodeId} - activeConfigSnapshot={activeConfigSnapshot} agentSoulConfig={buildDraft.agentSoulConfig} agentName={composerState?.agent?.name} currentModel={currentModel} textGenerationModelList={textGenerationModelList} - draftSavedAt={draftSavedAt} readOnly={buildDraft.isActive} isBuildDraftActive={buildDraft.isActive} buildDraftChangedKeys={buildDraft.changedKeys} diff --git a/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx b/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx index d1fd57d8c39..e83923ace43 100644 --- a/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx +++ b/web/features/agent-v2/agent-composer/__tests__/provider.spec.tsx @@ -1,4 +1,3 @@ -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import { render, screen } from '@testing-library/react' import { useAtomValue } from 'jotai' import { describe, expect, it } from 'vitest' @@ -6,35 +5,23 @@ import { defaultAgentSoulConfigFormState } from '../form-state' import { AgentComposerProvider } from '../provider' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, - hasAgentComposerUnpublishedChangesAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '../store' function StoreSnapshot() { const draft = useAtomValue(agentComposerDraftAtom) - const originalDraft = useAtomValue(agentComposerOriginalDraftAtom) - const publishedDraft = useAtomValue(agentComposerPublishedDraftAtom) - const originalConfig = useAtomValue(agentComposerOriginalConfigAtom) + const savedDraft = useAtomValue(agentComposerSavedDraftAtom) const isDirty = useAtomValue(isAgentComposerDirtyAtom) - const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom) return (
draft
{draft.prompt}
-
original draft
-
{originalDraft?.prompt}
-
published draft
-
{publishedDraft?.prompt}
-
original config
-
{originalConfig?.prompt?.system_prompt}
+
saved draft
+
{savedDraft?.prompt}
dirty
{String(isDirty)}
-
unpublished
-
{String(hasUnpublishedChanges)}
) } @@ -49,27 +36,15 @@ describe('AgentComposerProvider', () => { ...defaultAgentSoulConfigFormState, prompt: 'Be precise.', } - const initialOriginalConfig = { - prompt: { - system_prompt: 'Be precise.', - }, - } satisfies AgentSoulConfig - render( - + , ) expect(getDefinition('draft')).toHaveTextContent('Be precise.') - expect(getDefinition('original draft')).toHaveTextContent('Be precise.') - expect(getDefinition('published draft')).toHaveTextContent('Be precise.') - expect(getDefinition('original config')).toHaveTextContent('Be precise.') + expect(getDefinition('saved draft')).toHaveTextContent('Be precise.') expect(getDefinition('dirty')).toHaveTextContent('false') - expect(getDefinition('unpublished')).toHaveTextContent('false') }) it('creates a new scoped store when the composer session key changes', () => { @@ -96,7 +71,6 @@ describe('AgentComposerProvider', () => { ) expect(getDefinition('draft')).toHaveTextContent('Agent two draft') - expect(getDefinition('original draft')).toHaveTextContent('Agent two draft') - expect(getDefinition('published draft')).toHaveTextContent('Agent two draft') + expect(getDefinition('saved draft')).toHaveTextContent('Agent two draft') }) }) diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts index c16105c6d2e..ed7615457aa 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -5,9 +5,7 @@ import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conve import { defaultAgentSoulConfigFormState } from '../form-state' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, rebaseAgentComposerDraftAtom, } from '../store' @@ -91,23 +89,12 @@ describe('agent composer store conversions', () => { ...defaultAgentSoulConfigFormState, prompt: 'Build draft prompt', } - const originalConfig = { - prompt: { - system_prompt: 'Build draft prompt', - }, - } satisfies AgentSoulConfig - store.set(rebaseAgentComposerDraftAtom, { draft: nextDraft, - originalConfig, }) expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt') - expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt') - expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt') - expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe( - 'Build draft prompt', - ) + expect(store.get(agentComposerSavedDraftAtom)?.prompt).toBe('Build draft prompt') }) it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => { diff --git a/web/features/agent-v2/agent-composer/provider.tsx b/web/features/agent-v2/agent-composer/provider.tsx index 66b6d9aa305..2822ee37260 100644 --- a/web/features/agent-v2/agent-composer/provider.tsx +++ b/web/features/agent-v2/agent-composer/provider.tsx @@ -1,35 +1,25 @@ 'use client' -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { ReactNode } from 'react' import type { AgentSoulConfigFormState } from './form-state' import { ScopeProvider } from 'jotai-scope' import { defaultAgentSoulConfigFormState } from './form-state' -import { - agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, -} from './store' +import { agentComposerDraftAtom, agentComposerSavedDraftAtom } from './store' export function AgentComposerProvider({ children, initialDraft, - initialOriginalConfig, }: { children: ReactNode initialDraft?: AgentSoulConfigFormState - initialOriginalConfig?: AgentSoulConfig }) { const draft = initialDraft ?? defaultAgentSoulConfigFormState return ( diff --git a/web/features/agent-v2/agent-composer/store.ts b/web/features/agent-v2/agent-composer/store.ts index 330db0f22ab..752ceb546e1 100644 --- a/web/features/agent-v2/agent-composer/store.ts +++ b/web/features/agent-v2/agent-composer/store.ts @@ -1,14 +1,9 @@ -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { AgentSoulConfigFormState } from './form-state' import isEqual from 'fast-deep-equal' import { atom } from 'jotai' import { defaultAgentSoulConfigFormState } from './form-state' -export const agentComposerOriginalConfigAtom = atom(undefined) -export const agentComposerOriginalDraftAtom = atom( - defaultAgentSoulConfigFormState, -) -export const agentComposerPublishedDraftAtom = atom( +export const agentComposerSavedDraftAtom = atom( defaultAgentSoulConfigFormState, ) export const agentComposerDraftAtom = atom( @@ -22,29 +17,18 @@ export const rebaseAgentComposerDraftAtom = atom( set, { draft, - originalConfig, }: { draft: AgentSoulConfigFormState - originalConfig?: AgentSoulConfig }, ) => { - set(agentComposerOriginalConfigAtom, originalConfig) set(agentComposerDraftAtom, draft) - set(agentComposerOriginalDraftAtom, draft) - set(agentComposerPublishedDraftAtom, draft) + set(agentComposerSavedDraftAtom, draft) }, ) export const isAgentComposerDirtyAtom = atom((get) => { - const originalDraft = get(agentComposerOriginalDraftAtom) + const savedDraft = get(agentComposerSavedDraftAtom) const draft = get(agentComposerDraftAtom) - return !isEqual(draft, originalDraft ?? defaultAgentSoulConfigFormState) -}) - -export const hasAgentComposerUnpublishedChangesAtom = atom((get) => { - const publishedDraft = get(agentComposerPublishedDraftAtom) - const draft = get(agentComposerDraftAtom) - - return !isEqual(draft, publishedDraft ?? defaultAgentSoulConfigFormState) + return !isEqual(draft, savedDraft ?? defaultAgentSoulConfigFormState) }) diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index c147cb2543c..4c971ea3cdb 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -710,6 +710,55 @@ describe('AgentConfigurePage', () => { ).toBeVisible() expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toBeInTheDocument() }) + + it('should initialize the composer from recovered query data after the initial request fails', () => { + const queryClient = new QueryClient() + mocks.queryState.composer = { + data: undefined as unknown, + isFetching: false, + isError: true, + isPending: false, + isSuccess: false, + refetch: vi.fn(), + } + + const view = render( + + + , + ) + + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:yes', + ) + + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'recovered draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + view.rerender( + + + , + ) + + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'prompt:recovered draft prompt', + ) + expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent( + 'readonly:no', + ) + }) }) describe('Right panel mode', () => { diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index c5d1a13da47..7a24d7e501f 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -6,7 +6,7 @@ import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowl import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { agentComposerDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, } from '@/features/agent-v2/agent-composer/store' import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' @@ -32,9 +32,17 @@ const composerPutMutationFn = vi.hoisted(() => ), ) +const composerPutRequestContexts = vi.hoisted( + () => [] as Array<{ keepalive?: boolean; silent?: boolean } | undefined>, +) + const composerPutMutationOptions = vi.hoisted(() => vi.fn( (options?: { + context?: { + keepalive?: boolean + silent?: boolean + } onSuccess?: ( data: { agent_soul: Record }, variables: { @@ -51,6 +59,7 @@ const composerPutMutationOptions = vi.hoisted(() => agent_soul: Record } }) => { + composerPutRequestContexts.push(options?.context) const data = await composerPutMutationFn(variables) options?.onSuccess?.(data, variables) return data @@ -85,6 +94,9 @@ const publishAgentMutationFn = vi.hoisted(() => const publishAgentMutationOptions = vi.hoisted(() => vi.fn( (options?: { + context?: { + silent?: boolean + } onSuccess?: (data: PublishAgentResponse, variables: PublishAgentVariables) => void }) => ({ mutationFn: async (variables: PublishAgentVariables) => { @@ -98,11 +110,13 @@ const publishAgentMutationOptions = vi.hoisted(() => function createDeferredPromise() { let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve + reject = promiseReject }) - return { promise, resolve } + return { promise, reject, resolve } } function setDocumentVisibilityState(visibilityState: DocumentVisibilityState) { @@ -168,10 +182,12 @@ function renderUseAgentConfigureSync({ agentName = 'Agent', baseConfig, currentModel, + enabled = true, }: { agentName?: Parameters[0]['agentName'] baseConfig?: Parameters[0]['baseConfig'] currentModel?: Parameters[0]['currentModel'] + enabled?: boolean } = {}) { const queryClient = new QueryClient({ defaultOptions: { @@ -194,7 +210,7 @@ function renderUseAgentConfigureSync({ agentName, baseConfig, currentModel, - enabled: true, + enabled, }), { wrapper }, ), @@ -207,6 +223,7 @@ describe('useAgentConfigureSync', () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + composerPutRequestContexts.length = 0 }) afterEach(() => { @@ -215,16 +232,12 @@ describe('useAgentConfigureSync', () => { }) it('should automatically save configure page changes to draft', async () => { - vi.setSystemTime(1710000100000) - const { queryClient, result, store } = renderUseAgentConfigureSync() - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const { queryClient, store } = renderUseAgentConfigureSync() queryClient.setQueryData(['agent-detail', 'agent-1'], { active_config_is_published: true, name: 'Agent', }) - expect(result.current.draftSavedAt).toBeUndefined() - act(() => { store.set(agentComposerDraftAtom, { ...defaultAgentSoulConfigFormState, @@ -258,25 +271,14 @@ describe('useAgentConfigureSync', () => { }), }), ) - expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({ - agent_soul: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Draft only prompt', - }), - }), - }) expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({ active_config_is_published: true, name: 'Agent', }) - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['agent-detail', 'agent-1'], - }) - expect(result.current.draftSavedAt).toBe(1710000105000) }) it('should cancel pending autosave when the draft returns to the saved baseline', async () => { - const { queryClient, result, store } = renderUseAgentConfigureSync() + const { queryClient, store } = renderUseAgentConfigureSync() queryClient.setQueryData(['agent-detail', 'agent-1'], { active_config_is_published: true, name: 'Agent', @@ -301,7 +303,6 @@ describe('useAgentConfigureSync', () => { active_config_is_published: true, name: 'Agent', }) - expect(result.current.draftSavedAt).toBeUndefined() }) it('should save dirty draft once when the page is closing', async () => { @@ -326,6 +327,7 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + expect(composerPutRequestContexts).toEqual([{ keepalive: true, silent: true }]) expect(composerPutMutationFn).toHaveBeenCalledWith( expect.objectContaining({ params: { @@ -349,6 +351,101 @@ describe('useAgentConfigureSync', () => { }) }) + it('should dispatch the latest keepalive save while an earlier save is pending', async () => { + const saveDeferred = createDeferredPromise<{ agent_soul: Record }>() + composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise) + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Explicit save prompt', + }) + }) + + let saveDraftPromise!: Promise + act(() => { + saveDraftPromise = result.current.saveDraft() + }) + await act(async () => { + await Promise.resolve() + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Latest closing prompt', + }) + }) + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutRequestContexts).toEqual([ + { silent: true }, + { keepalive: true, silent: true }, + ]) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Latest closing prompt', + }), + }), + }), + }), + ) + + await act(async () => { + saveDeferred.resolve({ agent_soul: {} }) + await saveDraftPromise + await Promise.resolve() + }) + expect(store.get(agentComposerSavedDraftAtom)?.prompt).toBe('Latest closing prompt') + }) + + it('should repeat an in-flight explicit save with keepalive before unload', async () => { + const saveDeferred = createDeferredPromise<{ agent_soul: Record }>() + composerPutMutationFn.mockReturnValueOnce(saveDeferred.promise) + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Pending explicit save', + }) + }) + + let saveDraftPromise!: Promise + act(() => { + saveDraftPromise = result.current.saveDraft() + }) + await act(async () => { + await Promise.resolve() + }) + + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutRequestContexts).toEqual([ + { silent: true }, + { keepalive: true, silent: true }, + ]) + + await act(async () => { + saveDeferred.resolve({ agent_soul: {} }) + await saveDraftPromise + await Promise.resolve() + }) + }) + it('should save the latest dirty draft when Configure unmounts before autosave runs', async () => { const { store, unmount } = renderUseAgentConfigureSync() @@ -551,7 +648,7 @@ describe('useAgentConfigureSync', () => { }) it('should autosave when knowledge retrieval validation fails', async () => { - const { result, store } = renderUseAgentConfigureSync() + const { store } = renderUseAgentConfigureSync() act(() => { store.set(agentComposerDraftAtom, { @@ -571,12 +668,11 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) - expect(result.current.draftSavedAt).toBeDefined() }) it('should keep autosave failures silent and leave the local draft dirty', async () => { composerPutMutationFn.mockRejectedValueOnce(new Error('save failed')) - const { result, store } = renderUseAgentConfigureSync() + const { store } = renderUseAgentConfigureSync() act(() => { store.set(agentComposerDraftAtom, { @@ -590,13 +686,12 @@ describe('useAgentConfigureSync', () => { }) expect(composerPutMutationFn).toHaveBeenCalledTimes(1) - expect(result.current.draftSavedAt).toBeUndefined() expect(store.get(agentComposerDraftAtom).prompt).toBe('Unsaved autosave prompt') expect(toastMock.error).not.toHaveBeenCalled() + expect(composerPutRequestContexts).toEqual([{ silent: true }]) }) it('should save the latest draft immediately when requested', async () => { - vi.setSystemTime(1710000200000) const { result, store } = renderUseAgentConfigureSync() act(() => { @@ -627,7 +722,6 @@ describe('useAgentConfigureSync', () => { }), }), ) - expect(result.current.draftSavedAt).toBe(1710000200000) }) it('should reject explicit save requests when the draft cannot be saved', async () => { @@ -642,7 +736,6 @@ describe('useAgentConfigureSync', () => { }) await expect(result.current.saveDraft()).rejects.toThrow('Failed to save agent composer draft.') - expect(result.current.draftSavedAt).toBeUndefined() expect(store.get(agentComposerDraftAtom).prompt).toBe('Run prompt') expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') }) @@ -663,7 +756,6 @@ describe('useAgentConfigureSync', () => { active_config_is_published: true, name: 'Agent', }) - expect(result.current.draftSavedAt).toBeUndefined() }) it('should save the effective model before run when the form draft is unchanged', async () => { @@ -723,14 +815,9 @@ describe('useAgentConfigureSync', () => { }) it('should publish only when publishDraft is called explicitly', async () => { - const { queryClient, result, store } = renderUseAgentConfigureSync({ + const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, }) - const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') - queryClient.setQueryData(['agent-detail', 'agent-1'], { - active_config_is_published: false, - name: 'Agent', - }) act(() => { store.set(agentComposerDraftAtom, { ...defaultAgentSoulConfigFormState, @@ -764,13 +851,6 @@ describe('useAgentConfigureSync', () => { }, body: {}, }) - expect(invalidateQueries).toHaveBeenCalledWith({ - queryKey: ['agent-composer', 'agent-1'], - }) - expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({ - active_config_is_published: true, - name: 'Agent', - }) expect(trackEventMock).toHaveBeenCalledWith('app_published_time', { action_mode: 'app', app_id: 'agent-1', @@ -800,7 +880,7 @@ describe('useAgentConfigureSync', () => { expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel') }) - it('should keep default model fallback from creating unpublished changes after publish', async () => { + it('should keep default model fallback from leaving the local draft dirty after publish', async () => { const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, }) @@ -816,13 +896,13 @@ describe('useAgentConfigureSync', () => { }) expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) - const publishedDraft = store.get(agentComposerPublishedDraftAtom) + const savedDraft = store.get(agentComposerSavedDraftAtom) expect(store.get(agentComposerDraftAtom).model).toBeUndefined() - expect(publishedDraft?.model).toBeUndefined() - expect(publishedDraft).toEqual(store.get(agentComposerDraftAtom)) + expect(savedDraft?.model).toBeUndefined() + expect(savedDraft).toEqual(store.get(agentComposerDraftAtom)) }) - it('should keep base config fallback fields from creating unpublished changes after publish', async () => { + it('should keep base config fallback fields from leaving the local draft dirty after publish', async () => { const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, baseConfig: { @@ -845,10 +925,10 @@ describe('useAgentConfigureSync', () => { }) expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) - const publishedDraft = store.get(agentComposerPublishedDraftAtom) + const savedDraft = store.get(agentComposerSavedDraftAtom) expect(store.get(agentComposerDraftAtom).appFeatures).toBeUndefined() - expect(publishedDraft?.appFeatures).toBeUndefined() - expect(publishedDraft).toEqual(store.get(agentComposerDraftAtom)) + expect(savedDraft?.appFeatures).toBeUndefined() + expect(savedDraft).toEqual(store.get(agentComposerDraftAtom)) }) it('should publish the current draft snapshot instead of a stale caller payload', async () => { @@ -884,13 +964,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 { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, }) - queryClient.setQueryData(['agent-detail', 'agent-1'], { - active_config_is_published: false, - name: 'Agent', - }) act(() => { store.set(agentComposerDraftAtom, { @@ -904,13 +980,23 @@ describe('useAgentConfigureSync', () => { ) expect(publishAgentMutationFn).not.toHaveBeenCalled() - expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({ - active_config_is_published: false, - name: 'Agent', - }) expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') }) + it('should skip publish while the Composer Query is unavailable', async () => { + const { result } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + enabled: false, + }) + + await act(async () => { + await result.current.publishDraft() + }) + + expect(composerPutMutationFn).not.toHaveBeenCalled() + expect(publishAgentMutationFn).not.toHaveBeenCalled() + }) + it('should toast and skip publish when knowledge retrieval validation fails', async () => { const { result, store } = renderUseAgentConfigureSync({ currentModel: configuredModel, @@ -1000,4 +1086,180 @@ describe('useAgentConfigureSync', () => { expect(result.current.isPublishing).toBe(false) }) + + it('should pause autosave during publish and resume it for edits made in flight', async () => { + const publishDeferred = createDeferredPromise() + publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise) + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Draft captured for publish', + }) + }) + + let publishPromise!: Promise + act(() => { + publishPromise = result.current.publishDraft() + }) + await act(async () => { + await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Edited while publish is pending', + }) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + + await act(async () => { + publishDeferred.resolve({ + active_config_snapshot: {}, + active_config_snapshot_id: 'snapshot-1', + result: 'success', + }) + await publishPromise + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Edited while publish is pending', + }), + }), + }), + }), + ) + }) + + it('should dispatch a keepalive save for edits made while publish is pending', async () => { + const publishDeferred = createDeferredPromise() + publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise) + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Draft captured for publish', + }) + }) + + let publishPromise!: Promise + act(() => { + publishPromise = result.current.publishDraft() + }) + await act(async () => { + await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + expect(publishAgentMutationFn).toHaveBeenCalledTimes(1) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Latest edit before closing', + }) + }) + await act(async () => { + window.dispatchEvent(new Event('beforeunload')) + await Promise.resolve() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutRequestContexts).toEqual([ + { silent: true }, + { keepalive: true, silent: true }, + ]) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Latest edit before closing', + }), + }), + }), + }), + ) + + await act(async () => { + publishDeferred.resolve({ + active_config_snapshot: {}, + active_config_snapshot_id: 'snapshot-1', + result: 'success', + }) + await publishPromise + await Promise.resolve() + }) + }) + + it('should resume autosave for edits made while publish fails', async () => { + const publishDeferred = createDeferredPromise() + publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise) + const { result, store } = renderUseAgentConfigureSync({ + currentModel: configuredModel, + }) + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Draft captured for failed publish', + }) + }) + + let publishPromise!: Promise + act(() => { + publishPromise = result.current.publishDraft() + }) + await act(async () => { + await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Edited while failed publish is pending', + }) + }) + await act(async () => { + publishDeferred.reject(new Error('publish failed')) + await expect(publishPromise).rejects.toThrow('publish failed') + await Promise.resolve() + await Promise.resolve() + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(result.current.isPublishing).toBe(false) + expect(toastMock.error).toHaveBeenCalledTimes(1) + expect(composerPutMutationFn).toHaveBeenCalledTimes(2) + expect(composerPutMutationFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Edited while failed publish is pending', + }), + }), + }), + }), + ) + }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx index a845da96da4..15964340ff5 100644 --- a/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/composer-session.tsx @@ -99,7 +99,8 @@ export function AgentConfigureComposerScope({ } initializedComposerAgentIdRef.current = agentId - const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}` + const composerHydrationState = composerQuery.data === undefined ? 'unavailable' : 'loaded' + const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerHydrationState}:${composerRebaseRevision}` return ( { rebaseComposerDraft({ draft: agentSoulConfigToFormState(agentSoulConfig), - originalConfig: agentSoulConfig, }) }, [rebaseComposerDraft], ) const { currentModel, setConfigureModel, textGenerationModelList } = useAgentConfigureModelOptions() - const { draftSavedAt, isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({ + const { isPublishing, publishDraft, saveDraft } = useAgentConfigureSync({ agentId, agentName: agentQuery.data?.name, baseConfig: agentSoulConfig, @@ -449,20 +448,21 @@ function AgentConfigurePageComposerContent({ leftPanel={ ({ const workflowReferences = vi.hoisted(() => ({ fetchCount: 0, data: [] as AgentReferencingWorkflowResponse[], + shouldFail: false, +})) +const composerQuery = vi.hoisted(() => ({ + data: undefined as unknown, + shouldFail: false, })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -92,22 +96,39 @@ vi.mock('@/service/client', () => ({ 'agent-composer', input, ], + queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({ + queryKey: ['agent-composer', input], + queryFn: async () => { + if (composerQuery.shouldFail) throw new Error('Composer query failed') + + return composerQuery.data + }, + }), }, }, referencingWorkflows: { get: { queryOptions: ({ + context, enabled = true, input, }: { + context?: { silent?: boolean } enabled?: boolean input: { params: { agent_id: string } } }) => ({ queryKey: ['agent-referencing-workflows', input], enabled, - queryFn: async () => ({ - data: (workflowReferences.fetchCount++, workflowReferences.data), - }), + context, + queryFn: async () => { + workflowReferences.fetchCount++ + if (workflowReferences.shouldFail) + throw new Error('Workflow references query failed') + + return { + data: workflowReferences.data, + } + }, }), }, }, @@ -135,7 +156,7 @@ const activeConfigSnapshot: AgentConfigSnapshotSummaryResponse = { created_at: 1710000000, } -const originalDraftWithFile = { +const savedDraftWithFile = { ...defaultAgentSoulConfigFormState, tools: [ { @@ -184,6 +205,8 @@ function renderPublishBar({ activeConfigIsPublished, activeConfigSnapshot, draftSavedAt, + composerQueryAvailable = true, + composerQueryFails = false, isPublishing, onPublish = vi.fn(), onExitVersions = vi.fn(), @@ -192,11 +215,12 @@ function renderPublishBar({ selectedVersionSnapshot, setupStore, usedByAppReferences = [], - workflowReferencesEnabled, }: { activeConfigIsPublished?: boolean activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null draftSavedAt?: number + composerQueryAvailable?: boolean + composerQueryFails?: boolean isPublishing?: boolean onPublish?: PublishMock onExitVersions?: Mock<() => void> @@ -205,9 +229,9 @@ function renderPublishBar({ selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null setupStore?: (store: ReturnType) => void usedByAppReferences?: AgentReferencingWorkflowResponse[] - workflowReferencesEnabled?: boolean } = {}) { workflowReferences.data = usedByAppReferences + composerQuery.shouldFail = composerQueryFails const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -217,31 +241,41 @@ function renderPublishBar({ const store = createStore() store.set(agentComposerPromptAtom, prompt) setupStore?.(store) + const composerQueryKey = ['agent-composer', { params: { agent_id: 'agent-1' } }] + const composerState = { + active_config_is_published: activeConfigIsPublished ?? false, + active_config_snapshot: activeConfigSnapshot, + agent: { + id: 'agent-1', + name: 'Iris', + }, + agent_soul: { + schema_version: 1, + }, + draft: draftSavedAt + ? { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: draftSavedAt / 1000, + } + : null, + save_options: ['save_to_current_version'], + variant: 'agent_app', + } + composerQuery.data = composerState + if (composerQueryAvailable) { + queryClient.setQueryData(composerQueryKey, composerState) + } - const renderPublishBarTree = (nextProps?: { - activeConfigIsPublished?: boolean - activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null - isPublishing?: boolean - }) => ( + const renderPublishBarTree = (nextProps?: { isPublishing?: boolean }) => ( { }) workflowReferences.data = [] workflowReferences.fetchCount = 0 + workflowReferences.shouldFail = false + composerQuery.data = undefined + composerQuery.shouldFail = false vi.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -412,25 +449,35 @@ describe('AgentConfigurePublishBar', () => { expect(onPublish).not.toHaveBeenCalled() }) - it('should keep published state when the published detail updates before the active snapshot is refreshed', () => { - const { rerender, rerenderPublishBar } = renderPublishBar({ - activeConfigIsPublished: true, - activeConfigSnapshot: null, + it('should fail closed while the Composer Query is unavailable', async () => { + renderPublishBar({ + composerQueryAvailable: false, + composerQueryFails: true, }) - rerender( - rerenderPublishBar({ - activeConfigIsPublished: undefined, - activeConfigSnapshot: undefined, - }), + await waitFor(() => { + expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeDisabled() + }) + expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeDisabled() + expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( + expect.objectContaining({ enabled: false, ignoreInputs: false }), ) + }) - expect( - screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate'), - ).toBeInTheDocument() - expect( - screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' }), - ).toBeDisabled() + it('should fail closed when refreshing cached Composer state fails', async () => { + renderPublishBar({ + activeConfigIsPublished: false, + activeConfigSnapshot, + composerQueryFails: true, + }) + + await waitFor(() => { + expect( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ).toBeDisabled() + }) expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( expect.objectContaining({ enabled: false, ignoreInputs: false }), ) @@ -512,16 +559,15 @@ describe('AgentConfigurePublishBar', () => { }) }) - it('should publish without loading workflow references when references are disabled', async () => { + it('should fail closed and show feedback when workflow references cannot be loaded', async () => { + workflowReferences.shouldFail = true const { onPublish } = renderPublishBar({ activeConfigSnapshot, prompt: 'Updated system prompt', - usedByAppReferences: publishedReferences, - workflowReferencesEnabled: false, }) await waitFor(() => { - expect(workflowReferences.fetchCount).toBe(0) + expect(workflowReferences.fetchCount).toBe(1) }) fireEvent.click( screen.getByRole('button', { @@ -530,24 +576,18 @@ describe('AgentConfigurePublishBar', () => { ) await waitFor(() => { - expect(onPublish).toHaveBeenCalledTimes(1) + expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') }) - expect(workflowReferences.fetchCount).toBe(0) - expect( - screen.queryByRole('region', { - name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, - }), - ).not.toBeInTheDocument() + expect(onPublish).not.toHaveBeenCalled() }) it('should mark non-prompt draft changes as unpublished', () => { renderPublishBar({ activeConfigSnapshot, setupStore: (store) => { - store.set(agentComposerPublishedDraftAtom, originalDraftWithFile) - store.set(agentComposerOriginalDraftAtom, originalDraftWithFile) + store.set(agentComposerSavedDraftAtom, savedDraftWithFile) store.set(agentComposerDraftAtom, { - ...originalDraftWithFile, + ...savedDraftWithFile, tools: [], }) }, @@ -559,20 +599,16 @@ describe('AgentConfigurePublishBar', () => { }) it('should keep unpublished state after draft autosave updates the saved draft baseline', () => { - const publishedDraft = { - ...defaultAgentSoulConfigFormState, - prompt: 'Published prompt', - } const savedDraft = { ...defaultAgentSoulConfigFormState, prompt: 'Autosaved draft prompt', } renderPublishBar({ + activeConfigIsPublished: false, activeConfigSnapshot, setupStore: (store) => { - store.set(agentComposerPublishedDraftAtom, publishedDraft) - store.set(agentComposerOriginalDraftAtom, savedDraft) + store.set(agentComposerSavedDraftAtom, savedDraft) store.set(agentComposerDraftAtom, savedDraft) }, }) @@ -588,10 +624,6 @@ describe('AgentConfigurePublishBar', () => { }) it('should trust backend published state after autosave confirms the draft matches the active snapshot', () => { - const stalePublishedDraftBaseline = { - ...defaultAgentSoulConfigFormState, - prompt: 'Old unpublished normal draft', - } const savedDraftMatchingActiveSnapshot = { ...defaultAgentSoulConfigFormState, prompt: 'Published prompt', @@ -601,8 +633,7 @@ describe('AgentConfigurePublishBar', () => { activeConfigIsPublished: true, activeConfigSnapshot, setupStore: (store) => { - store.set(agentComposerPublishedDraftAtom, stalePublishedDraftBaseline) - store.set(agentComposerOriginalDraftAtom, savedDraftMatchingActiveSnapshot) + store.set(agentComposerSavedDraftAtom, savedDraftMatchingActiveSnapshot) store.set(agentComposerDraftAtom, savedDraftMatchingActiveSnapshot) }, }) @@ -757,6 +788,42 @@ describe('AgentConfigurePublishBar', () => { }) }) + it('should keep impact confirmation open without leaking a rejected publish command', async () => { + const onPublish = vi.fn(() => Promise.reject(new Error('publish failed'))) + renderPublishBar({ + activeConfigSnapshot, + onPublish, + prompt: 'Updated system prompt', + usedByAppReferences: publishedReferences, + }) + + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) + expect( + await screen.findByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() + + fireEvent.click( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/, + }), + ) + + await waitFor(() => { + expect(onPublish).toHaveBeenCalledTimes(1) + }) + expect( + screen.getByRole('region', { + name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, + }), + ).toBeInTheDocument() + }) + it('should collapse affected workflow details from the expanded footer cancel action', async () => { const { onPublish } = renderPublishBar({ activeConfigSnapshot, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx index 4c036deed2b..1c78561972b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx @@ -1,4 +1,3 @@ -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { AgentConfigApiContext } from '../../config-context' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { toast } from '@langgenius/dify-ui/toast' @@ -170,12 +169,10 @@ function createInitialDraft( function renderAgentFiles({ initialDraft = createInitialDraft(), - initialOriginalConfig, apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext, readOnly = false, }: { initialDraft?: AgentSoulConfigFormState - initialOriginalConfig?: AgentSoulConfig apiContext?: AgentConfigApiContext readOnly?: boolean } = {}) { @@ -193,10 +190,7 @@ function renderAgentFiles({ return render( - + diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index e05cae17669..cd16df3e505 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -33,18 +33,14 @@ type AgentOrchestratePanelProps = { agentId: string appId?: string nodeId?: string - activeConfigIsPublished?: boolean - activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot'] agentName?: string | null currentModel?: AgentComposerModel textGenerationModelList: Model[] - draftSavedAt?: number isPublishing?: boolean className?: string readOnly?: boolean selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null - workflowReferencesEnabled?: boolean isBuildDraftActive?: boolean buildDraftChangedKeys?: readonly AgentBuildDraftChangedKey[] showHeader?: boolean @@ -62,18 +58,14 @@ export function AgentOrchestratePanel({ agentId, appId, nodeId, - activeConfigIsPublished, - activeConfigSnapshot, agentSoulConfig: _agentSoulConfig, agentName, currentModel, textGenerationModelList, - draftSavedAt, isPublishing, className, readOnly = false, selectedVersionSnapshot, - workflowReferencesEnabled, isBuildDraftActive = false, buildDraftChangedKeys = [], showHeader = true, @@ -94,13 +86,9 @@ export function AgentOrchestratePanel({ (showPublishBar ? ( void | Promise onExitVersions?: () => void onOpenVersions?: () => void @@ -52,13 +45,11 @@ function getPublishState({ activeConfigIsPublished, activeConfigSnapshot, hasLocalChanges, - hasUnpublishedChanges, isPublishing, }: { activeConfigIsPublished?: boolean activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null hasLocalChanges: boolean - hasUnpublishedChanges: boolean isPublishing: boolean }): AgentConfigurePublishState { if (isPublishing) return 'publishing' @@ -67,13 +58,9 @@ function getPublishState({ if (activeConfigIsPublished) return 'published' - if (hasUnpublishedChanges) return 'unpublished' - if (!activeConfigSnapshot) return 'draft' - if (!activeConfigIsPublished) return 'unpublished' - - return 'published' + return 'unpublished' } function PublishShortcut() { @@ -90,13 +77,9 @@ function PublishShortcut() { export function AgentConfigurePublishBar({ agentId, - activeConfigIsPublished, - activeConfigSnapshot, agentName, - draftSavedAt, isPublishing = false, selectedVersionSnapshot, - workflowReferencesEnabled = true, onPublish, onExitVersions, onOpenVersions, @@ -107,29 +90,37 @@ export function AgentConfigurePublishBar({ const { formatTimeFromNow } = useFormatTimeFromNow() const queryClient = useQueryClient() const [publishBarMode, setPublishBarMode] = useState({ status: 'compact' }) - const lastKnownPublishedRef = useRef(false) - if (activeConfigIsPublished === true) lastKnownPublishedRef.current = true - if (activeConfigIsPublished === false) lastKnownPublishedRef.current = false - const stableActiveConfigIsPublished = - activeConfigIsPublished ?? (lastKnownPublishedRef.current ? true : undefined) - const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom) + const composerQuery = useQuery( + consoleQuery.agent.byAgentId.composer.get.queryOptions({ + input: { + params: { + agent_id: agentId, + }, + }, + }), + ) + const activeConfigIsPublished = composerQuery.data?.active_config_is_published + const activeConfigSnapshot = composerQuery.data?.active_config_snapshot + const draftSavedAt = composerQuery.data?.draft?.updated_at + ? composerQuery.data.draft.updated_at * 1000 + : undefined const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom) const publishableState = getPublishState({ - activeConfigIsPublished: stableActiveConfigIsPublished, + activeConfigIsPublished, activeConfigSnapshot, hasLocalChanges, - hasUnpublishedChanges, isPublishing: false, }) const publishState = getPublishState({ - activeConfigIsPublished: stableActiveConfigIsPublished, + activeConfigIsPublished, activeConfigSnapshot, hasLocalChanges, - hasUnpublishedChanges, isPublishing, }) const publishIsAvailable = - !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished') + composerQuery.isSuccess && + !isPublishing && + (publishableState === 'draft' || publishableState === 'unpublished') const workflowReferencesQueryOptions = consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ input: { @@ -137,7 +128,10 @@ export function AgentConfigurePublishBar({ agent_id: agentId, }, }, - enabled: workflowReferencesEnabled && publishIsAvailable && !selectedVersionSnapshot, + context: { + silent: true, + }, + enabled: publishIsAvailable && !selectedVersionSnapshot, }) const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions) const restoreVersionMutation = useMutation( @@ -206,16 +200,19 @@ export function AgentConfigurePublishBar({ return } - const cachedReferences = queryClient.getQueryData( - workflowReferencesQueryOptions.queryKey, - ) - const references = workflowReferencesEnabled - ? (( - cachedReferences ?? - workflowReferencesQuery.data ?? - (await queryClient.ensureQueryData(workflowReferencesQueryOptions)) - )?.data ?? []) - : [] + let referencesResponse: AgentReferencingWorkflowsResponse | undefined + try { + referencesResponse = + queryClient.getQueryData( + workflowReferencesQueryOptions.queryKey, + ) ?? + workflowReferencesQuery.data ?? + (await queryClient.ensureQueryData(workflowReferencesQueryOptions)) + } catch { + toast.error(tCommon(($) => $['api.actionFailed'])) + return + } + const references = referencesResponse?.data ?? [] if (references.length > 0) { setPublishBarMode({ status: 'confirmingImpact', references }) @@ -225,11 +222,15 @@ export function AgentConfigurePublishBar({ await handlePublish() } + const requestPublish = () => { + void handlePublishRequest().catch(() => undefined) + } + useHotkey( PUBLISH_AGENT_HOTKEY, (event) => { event.preventDefault() - void handlePublishRequest() + requestPublish() }, { enabled: canPublish && !selectedVersionSnapshot, @@ -331,7 +332,7 @@ export function AgentConfigurePublishBar({ canPublish={canPublish} onCancelImpact={() => setPublishBarMode({ status: 'compact' })} onOpenVersions={() => onOpenVersions?.()} - onPublishRequest={handlePublishRequest} + onPublishRequest={requestPublish} /> ) @@ -360,7 +361,7 @@ function PublishBarActions({ canPublish: boolean onCancelImpact: () => void onOpenVersions: () => void - onPublishRequest: () => void | Promise + onPublishRequest: () => void }) { const { t } = useTranslation('agentV2') @@ -402,9 +403,7 @@ function PublishBarActions({ disabled={!canPublish} loading={isPublishing} className="h-8 gap-1 rounded-lg px-3" - onClick={() => { - void onPublishRequest() - }} + onClick={onPublishRequest} > {actionIcon && } {actionLabel} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx index 472f3a91044..6419ea4c7c1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx @@ -12,8 +12,7 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' import { agentComposerDraftAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' import { AgentOrchestrateReadOnlyContext } from '../../read-only-context' @@ -325,8 +324,7 @@ function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agen }) const store = createStore() store.set(agentComposerDraftAtom, initialDraft) - store.set(agentComposerOriginalDraftAtom, initialDraft) - store.set(agentComposerPublishedDraftAtom, initialDraft) + store.set(agentComposerSavedDraftAtom, initialDraft) const view = render( diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts index 4496ff774c3..6e77988a1f0 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-sync.ts @@ -4,11 +4,11 @@ import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.ge import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { toast } from '@langgenius/dify-ui/toast' -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query' import { debounce } from 'es-toolkit/compat' import isEqual from 'fast-deep-equal' import { useSetAtom, useStore } from 'jotai' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' @@ -19,9 +19,7 @@ import { } from '@/features/agent-v2/agent-composer/knowledge-validation' import { agentComposerDraftAtom, - agentComposerOriginalConfigAtom, - agentComposerOriginalDraftAtom, - agentComposerPublishedDraftAtom, + agentComposerSavedDraftAtom, isAgentComposerDirtyAtom, } from '@/features/agent-v2/agent-composer/store' import { consoleQuery } from '@/service/client' @@ -45,15 +43,13 @@ export function useAgentConfigureSync({ const getKnowledgeValidationMessage = useKnowledgeValidationMessage() const queryClient = useQueryClient() const store = useStore() - const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom) - const setOriginalDraft = useSetAtom(agentComposerOriginalDraftAtom) - const setPublishedDraft = useSetAtom(agentComposerPublishedDraftAtom) - const [draftSavedAt, setDraftSavedAt] = useState(undefined) - const [isPublishInFlight, setIsPublishInFlight] = useState(false) + const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom) const baseConfigRef = useRef(baseConfig) const currentModelRef = useRef(currentModel) const enabledRef = useRef(enabled) const lastAutosavedDraftKeyRef = useRef(undefined) + const latestAppliedSaveSequenceRef = useRef(0) + const nextSaveSequenceRef = useRef(0) const pageCloseSavingDraftKeyRef = useRef(undefined) const explicitlySavingDraftKeysRef = useRef(new Set()) const publishInFlightRef = useRef(false) @@ -73,28 +69,63 @@ export function useAgentConfigureSync({ ) const { mutateAsync: saveComposerDraft } = useMutation( - consoleQuery.agent.byAgentId.composer.put.mutationOptions(), + consoleQuery.agent.byAgentId.composer.put.mutationOptions({ + context: { + silent: true, + }, + }), ) - const { isPending: isPublishingAgent, mutateAsync: publishAgent } = useMutation( - consoleQuery.agent.byAgentId.publish.post.mutationOptions(), + const { mutateAsync: saveComposerDraftOnPageClose } = useMutation( + consoleQuery.agent.byAgentId.composer.put.mutationOptions({ + context: { + keepalive: true, + silent: true, + }, + }), + ) + const { mutateAsync: publishAgent } = useMutation( + consoleQuery.agent.byAgentId.publish.post.mutationOptions({ + context: { + silent: true, + }, + }), + ) + + const applySavedDraft = useCallback( + ({ + draftBaseline, + draftKey, + saveSequence, + }: { + draftBaseline: AgentSoulConfigFormState + draftKey: string + saveSequence: number + }) => { + if (saveSequence < latestAppliedSaveSequenceRef.current) return + + latestAppliedSaveSequenceRef.current = saveSequence + setSavedDraft(draftBaseline) + lastAutosavedDraftKeyRef.current = draftKey + }, + [setSavedDraft], ) const saveComposer = useSerialAsyncCallback( async ({ configSnapshot, draftBaseline, + publish = false, silent = true, }: { configSnapshot: AgentSoulConfig draftBaseline: AgentSoulConfigFormState + publish?: boolean silent?: boolean }) => { const savedDraftKey = JSON.stringify(configSnapshot) - const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ - input: { params: { agent_id: agentId } }, - }) + const saveSequence = ++nextSaveSequenceRef.current try { - const composerState = await saveComposerDraft({ + await saveComposerDraft({ params: { agent_id: agentId, }, @@ -104,32 +135,93 @@ export function useAgentConfigureSync({ agent_soul: configSnapshot, }, }) - queryClient.setQueryData( - consoleQuery.agent.byAgentId.composer.get.queryKey({ - input: { params: { agent_id: agentId } }, - }), - composerState, - ) - await queryClient.invalidateQueries({ - queryKey: agentDetailQueryKey, - }) } catch { // Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary. if (!silent) { - toast.error(tCommon(($) => $['api.actionFailed'])) throw new Error('Failed to save agent composer draft.') } return false } - setOriginalDraft(draftBaseline) - setDraftSavedAt(Date.now()) - lastAutosavedDraftKeyRef.current = savedDraftKey + applySavedDraft({ + draftBaseline, + draftKey: savedDraftKey, + saveSequence, + }) + + if (publish) { + await publishAgent({ + params: { + agent_id: agentId, + }, + body: {}, + }) + await queryClient.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.versions.get.key(), + }) + } + return true }, ) + const saveComposerOnPageClose = useCallback( + async ({ + configSnapshot, + draftBaseline, + draftKey, + }: { + configSnapshot: AgentSoulConfig + draftBaseline: AgentSoulConfigFormState + draftKey: string + }) => { + const saveSequence = ++nextSaveSequenceRef.current + try { + await saveComposerDraftOnPageClose({ + params: { + agent_id: agentId, + }, + body: { + variant: 'agent_app', + save_strategy: 'save_to_current_version', + agent_soul: configSnapshot, + }, + }) + } catch { + return false + } + + applySavedDraft({ + draftBaseline, + draftKey, + saveSequence, + }) + return true + }, + [agentId, applySavedDraft, saveComposerDraftOnPageClose], + ) + + const { isPending: isPublishing, mutateAsync: runPublishTransaction } = useMutation( + mutationOptions({ + mutationKey: ['agent-configure', agentId, 'publish'], + mutationFn: async ({ + configSnapshot, + draftBaseline, + }: { + configSnapshot: AgentSoulConfig + draftBaseline: AgentSoulConfigFormState + }) => { + await saveComposer({ + configSnapshot, + draftBaseline, + publish: true, + silent: false, + }) + }, + }), + ) + const latestDraftSaveRef = useRef<() => void>(() => undefined) latestDraftSaveRef.current = () => { const draft = store.get(agentComposerDraftAtom) @@ -165,41 +257,46 @@ export function useAgentConfigureSync({ draftBaseline: draft, silent: false, }) + } catch (error) { + toast.error(tCommon(($) => $['api.actionFailed'])) + throw error } finally { explicitlySavingDraftKeysRef.current.delete(draftKey) } - }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store]) + }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store, tCommon]) - const saveDirtyDraftOnPageClose = useCallback(() => { - if (!enabledRef.current || publishInFlightRef.current) { - return - } + const saveDirtyDraftOnPageClose = useCallback( + (allowInFlightDuplicate = false) => { + if (!enabledRef.current) return - const draft = store.get(agentComposerDraftAtom) - if (!store.get(isAgentComposerDirtyAtom)) { - return - } + const draft = store.get(agentComposerDraftAtom) + if (!store.get(isAgentComposerDirtyAtom)) { + return + } - const configSnapshot = getAgentSoulDraft() - const draftKey = JSON.stringify(configSnapshot) - if ( - lastAutosavedDraftKeyRef.current === draftKey || - pageCloseSavingDraftKeyRef.current === draftKey || - explicitlySavingDraftKeysRef.current.has(draftKey) - ) { - return - } + const configSnapshot = getAgentSoulDraft() + const draftKey = JSON.stringify(configSnapshot) + if ( + lastAutosavedDraftKeyRef.current === draftKey || + pageCloseSavingDraftKeyRef.current === draftKey || + (!allowInFlightDuplicate && explicitlySavingDraftKeysRef.current.has(draftKey)) + ) { + return + } - debouncedSaveDraft.cancel?.() - pageCloseSavingDraftKeyRef.current = draftKey - void saveComposer({ - configSnapshot, - draftBaseline: draft, - }).finally(() => { - if (pageCloseSavingDraftKeyRef.current === draftKey) - pageCloseSavingDraftKeyRef.current = undefined - }) - }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store]) + debouncedSaveDraft.cancel?.() + pageCloseSavingDraftKeyRef.current = draftKey + void saveComposerOnPageClose({ + configSnapshot, + draftBaseline: draft, + draftKey, + }).finally(() => { + if (pageCloseSavingDraftKeyRef.current === draftKey) + pageCloseSavingDraftKeyRef.current = undefined + }) + }, + [debouncedSaveDraft, getAgentSoulDraft, saveComposerOnPageClose, store], + ) useEffect(() => { return store.sub(agentComposerDraftAtom, () => { @@ -207,7 +304,7 @@ export function useAgentConfigureSync({ const agentSoulDraftKey = JSON.stringify(agentSoulDraft) const isDirty = store.get(isAgentComposerDirtyAtom) - if (!enabledRef.current || !isDirty) { + if (!enabledRef.current || publishInFlightRef.current || !isDirty) { if (!isDirty) debouncedSaveDraft.cancel?.() return } @@ -222,10 +319,10 @@ export function useAgentConfigureSync({ useEffect(() => { const saveDraftWhenPageHidden = () => { - if (document.visibilityState === 'hidden') saveDirtyDraftOnPageClose() + if (document.visibilityState === 'hidden') saveDirtyDraftOnPageClose(true) } const saveDraftBeforeUnload = () => { - saveDirtyDraftOnPageClose() + saveDirtyDraftOnPageClose(true) } document.addEventListener('visibilitychange', saveDraftWhenPageHidden) @@ -244,7 +341,7 @@ export function useAgentConfigureSync({ }, [saveDirtyDraftOnPageClose]) const publishDraft = useCallback(async () => { - if (publishInFlightRef.current) return + if (!enabledRef.current || publishInFlightRef.current) return const draft = store.get(agentComposerDraftAtom) const configSnapshot = formStateToAgentSoulConfig({ @@ -267,45 +364,12 @@ export function useAgentConfigureSync({ } publishInFlightRef.current = true - setIsPublishInFlight(true) try { debouncedSaveDraft.cancel?.() - const saved = await saveComposer({ + await runPublishTransaction({ configSnapshot, draftBaseline: draft, - silent: false, }) - if (!saved) return - - await publishAgent({ - params: { - agent_id: agentId, - }, - body: {}, - }) - queryClient.setQueryData( - consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }), - (agentDetail) => { - if (!agentDetail) return agentDetail - - return { - ...agentDetail, - active_config_is_published: true, - } - }, - ) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.composer.get.queryKey({ - input: { params: { agent_id: agentId } }, - }), - }) - void queryClient.invalidateQueries({ - queryKey: consoleQuery.agent.byAgentId.versions.get.key(), - }) - setOriginalConfig(configSnapshot) - const publishedDraft = draft - setOriginalDraft(publishedDraft) - setPublishedDraft(publishedDraft) trackEvent('app_published_time', { action_mode: 'app', app_id: agentId, @@ -313,28 +377,25 @@ export function useAgentConfigureSync({ app_mode: 'agent-v2', }) toast.success(tCommon(($) => $['api.actionSuccess'])) + } catch (error) { + toast.error(tCommon(($) => $['api.actionFailed'])) + throw error } finally { publishInFlightRef.current = false - setIsPublishInFlight(false) + if (enabledRef.current && store.get(isAgentComposerDirtyAtom)) debouncedSaveDraft() } }, [ agentId, agentName, debouncedSaveDraft, getKnowledgeValidationMessage, - publishAgent, - queryClient, - saveComposer, - setOriginalConfig, - setOriginalDraft, - setPublishedDraft, + runPublishTransaction, store, tCommon, ]) return { - draftSavedAt, - isPublishing: isPublishInFlight || isPublishingAgent, + isPublishing, publishDraft, saveDraft, } diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index edd231cb3fa..21e0a9caf1e 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -129,6 +129,12 @@ const createComposerState = ( id: 'snapshot-1', version: 1, }, + draft: { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: 1710000100, + }, agent: { active_config_snapshot_id: 'snapshot-1', description: 'Agent description', @@ -156,6 +162,12 @@ const createAgentPublishResponse = ( version: 1, }, active_config_snapshot_id: 'snapshot-1', + draft: { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: 1710000200, + }, result: 'success', ...overrides, }) @@ -966,10 +978,43 @@ describe('consoleQuery agent mutation defaults', () => { page: 1, total: 1, }) + const composerQueryKey = consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + }) + queryClient.setQueryData( + composerQueryKey, + createComposerState({ + active_config_snapshot: { + id: 'snapshot-previous', + version: 1, + }, + agent_soul: { + config_note: 'Keep the cached composer state', + schema_version: 1, + }, + }), + ) + const publishResponse = createAgentPublishResponse({ + active_config_snapshot: { + id: 'snapshot-2', + version: 2, + }, + active_config_snapshot_id: 'snapshot-2', + draft: { + agent_id: 'agent-1', + draft_type: 'draft', + id: 'draft-1', + updated_at: 1710000300, + }, + }) const mutationOptions = consoleQuery.agent.byAgentId.publish.post.mutationOptions() await mutationOptions.onSuccess?.( - createAgentPublishResponse(), + publishResponse, { params: { agent_id: 'agent-1', @@ -990,16 +1035,40 @@ describe('consoleQuery agent mutation defaults', () => { queryKey: consoleQuery.agent.inviteOptions.get.key(), }) expect(queryClient.getQueryData(inviteOptionsQueryKey)).toBeUndefined() + expect(queryClient.getQueryData(composerQueryKey)).toEqual( + expect.objectContaining({ + active_config_is_published: true, + active_config_snapshot: publishResponse.active_config_snapshot, + agent_soul: { + config_note: 'Keep the cached composer state', + schema_version: 1, + }, + draft: publishResponse.draft, + }), + ) }) it('should invalidate roster list but keep invite options stable after saving an agent draft', async () => { const consoleQuery = await loadConsoleQuery() const queryClient = new QueryClient() const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const composerQueryKey = consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + }) + const savedComposerState = createComposerState({ + agent_soul: { + config_note: 'Saved composer state', + schema_version: 1, + }, + }) const mutationOptions = consoleQuery.agent.byAgentId.composer.put.mutationOptions() await mutationOptions.onSuccess?.( - createComposerState(), + savedComposerState, { params: { agent_id: 'agent-1', @@ -1022,6 +1091,7 @@ describe('consoleQuery agent mutation defaults', () => { expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: consoleQuery.agent.inviteOptions.get.key(), }) + expect(queryClient.getQueryData(composerQueryKey)).toEqual(savedComposerState) }) it('should invalidate invite option lists after deleting an agent', async () => { diff --git a/web/service/client.ts b/web/service/client.ts index 354b592baf3..0e6dfb3697d 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -1,4 +1,7 @@ -import type { AgentAppPagination } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentAppComposerResponse, + AgentAppPagination, +} from '@dify/contracts/api/console/agent/types.gen' import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen' import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen' import type { consoleRouterContract } from '@dify/contracts/console' @@ -662,7 +665,17 @@ export const consoleQuery: RouterUtils = createTanstackQue composer: { put: { mutationOptions: { - onSuccess: (_composerState, variables, _onMutateResult, context) => { + onSuccess: (composerState, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: variables.params.agent_id, + }, + }, + }), + composerState, + ) context.client.invalidateQueries({ queryKey: consoleQuery.agent.get.key(), }) @@ -681,7 +694,33 @@ export const consoleQuery: RouterUtils = createTanstackQue publish: { post: { mutationOptions: { - onSuccess: (_publishResult, _variables, _onMutateResult, context) => { + onSuccess: (publishResult, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.agent.byAgentId.composer.get.queryKey({ + input: { + params: { + agent_id: variables.params.agent_id, + }, + }, + }), + (composerState) => { + if (!composerState) return composerState + + return { + ...composerState, + active_config_is_published: true, + active_config_snapshot: publishResult.active_config_snapshot, + agent: { + ...composerState.agent, + active_config_snapshot_id: publishResult.active_config_snapshot_id, + }, + draft: + publishResult.draft === undefined + ? composerState.draft + : publishResult.draft, + } + }, + ) context.client.invalidateQueries({ queryKey: consoleQuery.agent.get.key(), }) From 1a7ffbe5ee127566d7798103e47c9db7d8cc43bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:57:44 +0800 Subject: [PATCH 044/531] chore: bump gitpython from 3.1.52 to 3.1.54 in /api (#39619) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 125cfca452b..3bf03782562 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -2710,14 +2710,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.52" +version = "3.1.54" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/fd/df0bafa4eb5ea2f51e1adee9f7a94c8e62c5d180e65117045dfca3439c8a/gitpython-3.1.52.tar.gz", hash = "sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e", size = 223726, upload-time = "2026-07-16T03:15:59.599Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/d5/3da0b92033887033f4c27f2dd109a303c4ca62813c7b3bb2511edb4777de/gitpython-3.1.54.tar.gz", hash = "sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0", size = 225076, upload-time = "2026-07-22T04:08:51.403Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/90/04dff7c1e176bb1c3011ef1647393d368790da710d8dde1cdcfad301f45a/gitpython-3.1.52-py3-none-any.whl", hash = "sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a", size = 215366, upload-time = "2026-07-16T03:15:58.239Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b9/876f442a28df5c068ca69b0122d5c35e65fd2d2fa9992ea5cb5944ea00a6/gitpython-3.1.54-py3-none-any.whl", hash = "sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf", size = 216575, upload-time = "2026-07-22T04:08:50.05Z" }, ] [[package]] From 42f9610ba5e19395037354caf86e75d573569121 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 17:57:51 +0900 Subject: [PATCH 045/531] test: use sqlite3 session in test_api (#38733) --- .../core/moderation/api/test_api.py | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/api/tests/unit_tests/core/moderation/api/test_api.py b/api/tests/unit_tests/core/moderation/api/test_api.py index 558b20e5f88..e2969e837cc 100644 --- a/api/tests/unit_tests/core/moderation/api/test_api.py +++ b/api/tests/unit_tests/core/moderation/api/test_api.py @@ -2,13 +2,24 @@ from unittest.mock import MagicMock, patch import pytest from pydantic import ValidationError +from sqlalchemy.orm import Session +import core.moderation.api.api as moderation_module from core.extension.api_based_extension_requestor import APIBasedExtensionPoint from core.moderation.api.api import ApiModeration, ModerationInputParams, ModerationOutputParams from core.moderation.base import ModerationAction, ModerationInputsResult, ModerationOutputsResult from models.api_based_extension import APIBasedExtension +class _DatabaseBinding: + """Expose the real SQLite session used by extension lookup.""" + + session: Session + + def __init__(self, session: Session) -> None: + self.session = session + + class TestApiModeration: @pytest.fixture def api_config(self): @@ -165,17 +176,27 @@ class TestApiModeration: with pytest.raises(ValueError, match="API-based Extension not found"): api_moderation._get_config_by_requestor(APIBasedExtensionPoint.APP_MODERATION_INPUT, {}) - @patch("core.moderation.api.api.db.session.scalar") - def test_get_api_based_extension(self, mock_scalar): - mock_ext = MagicMock(spec=APIBasedExtension) - mock_scalar.return_value = mock_ext + @pytest.mark.parametrize("sqlite_session", [(APIBasedExtension,)], indirect=True) + def test_get_api_based_extension(self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + target = APIBasedExtension( + tenant_id="tenant-1", + name="Target extension", + api_endpoint="https://example.com/moderate", + api_key="encrypted-key", + ) + target.id = "ext-1" + other_tenant = APIBasedExtension( + tenant_id="tenant-2", + name="Other extension", + api_endpoint="https://example.com/other", + api_key="other-key", + ) + other_tenant.id = "ext-2" + sqlite_session.add_all((target, other_tenant)) + sqlite_session.commit() + monkeypatch.setattr(moderation_module, "db", _DatabaseBinding(sqlite_session)) result = ApiModeration._get_api_based_extension("tenant-1", "ext-1") - assert result == mock_ext - mock_scalar.assert_called_once() - # Verify the call has the correct filters - args, kwargs = mock_scalar.call_args - stmt = args[0] - # We can't easily inspect the statement without complex sqlalchemy tricks, - # but calling it is usually enough for unit tests if we mock the result. + assert result is target + assert ApiModeration._get_api_based_extension("tenant-1", "ext-2") is None From a28969f564e29e109d552fb5d69e641e74b47499 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:58:40 +0800 Subject: [PATCH 046/531] chore(deps): bump golang.org/x/net from 0.53.0 to 0.55.0 in /dify-agent-runtime (#39025) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dify-agent-runtime/go.mod | 6 +++--- dify-agent-runtime/go.sum | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/dify-agent-runtime/go.mod b/dify-agent-runtime/go.mod index 297b144dcd1..37f9e7b9e3c 100644 --- a/dify-agent-runtime/go.mod +++ b/dify-agent-runtime/go.mod @@ -19,9 +19,9 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect modernc.org/libc v1.65.7 // indirect diff --git a/dify-agent-runtime/go.sum b/dify-agent-runtime/go.sum index b4fbc8b3322..253a5c6f1d8 100644 --- a/dify-agent-runtime/go.sum +++ b/dify-agent-runtime/go.sum @@ -45,19 +45,19 @@ go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLh go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= From 01e736aaf71c45918e1a24d6d70b769a43f54a15 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 18:00:47 +0900 Subject: [PATCH 047/531] test: use sqlite3 session in test_extract_thread_messages (#38739) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../prompt/test_extract_thread_messages.py | 98 +++++++++++++++---- 1 file changed, 81 insertions(+), 17 deletions(-) diff --git a/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py b/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py index 3b38a9af40a..a9438e6aa18 100644 --- a/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py +++ b/api/tests/unit_tests/core/prompt/test_extract_thread_messages.py @@ -1,9 +1,42 @@ -from unittest.mock import MagicMock +from datetime import datetime, timedelta +from decimal import Decimal from uuid import uuid4 +import pytest +from sqlalchemy.orm import Session + from constants import UUID_NIL from core.prompt.utils.extract_thread_messages import extract_thread_messages from core.prompt.utils.get_thread_messages_length import get_thread_messages_length +from models.enums import ConversationFromSource +from models.model import Message + + +def _persisted_message( + *, + message_id: str, + conversation_id: str, + parent_message_id: str, + answer: str, + created_at: datetime, +) -> Message: + message = Message( + id=message_id, + app_id="app-id", + conversation_id=conversation_id, + query="question", + message={"role": "user", "content": "question"}, + answer=answer, + message_unit_price=Decimal("0.0001"), + answer_unit_price=Decimal("0.0001"), + currency="USD", + from_source=ConversationFromSource.API, + parent_message_id=parent_message_id, + created_at=created_at, + updated_at=created_at, + ) + message._inputs = {} + return message class MockMessage: @@ -104,33 +137,64 @@ def test_extract_thread_messages_breaks_when_parent_is_none(): assert result[0].id == id2 -def test_get_thread_messages_length_excludes_newly_created_empty_answer(): +@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) +def test_get_thread_messages_length_excludes_newly_created_empty_answer(sqlite_session: Session): id1, id2 = str(uuid4()), str(uuid4()) + now = datetime.now() messages = [ - MockMessage(id2, id1, answer=""), # newest generated message should be excluded - MockMessage(id1, UUID_NIL, answer="ok"), + _persisted_message( + message_id=id2, + conversation_id="conversation-1", + parent_message_id=id1, + answer="", + created_at=now, + ), + _persisted_message( + message_id=id1, + conversation_id="conversation-1", + parent_message_id=UUID_NIL, + answer="ok", + created_at=now - timedelta(seconds=1), + ), + _persisted_message( + message_id=str(uuid4()), + conversation_id="other-conversation", + parent_message_id=UUID_NIL, + answer="unrelated", + created_at=now + timedelta(seconds=1), + ), ] + sqlite_session.add_all(messages) + sqlite_session.commit() - session = MagicMock() - session.scalars.return_value.all.return_value = messages - - length = get_thread_messages_length("conversation-1", session=session) + length = get_thread_messages_length("conversation-1", session=sqlite_session) assert length == 1 - session.scalars.assert_called_once() -def test_get_thread_messages_length_keeps_non_empty_latest_answer(): +@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) +def test_get_thread_messages_length_keeps_non_empty_latest_answer(sqlite_session: Session): id1, id2 = str(uuid4()), str(uuid4()) + now = datetime.now() messages = [ - MockMessage(id2, id1, answer="latest-answer"), - MockMessage(id1, UUID_NIL, answer="older-answer"), + _persisted_message( + message_id=id2, + conversation_id="conversation-2", + parent_message_id=id1, + answer="latest-answer", + created_at=now, + ), + _persisted_message( + message_id=id1, + conversation_id="conversation-2", + parent_message_id=UUID_NIL, + answer="older-answer", + created_at=now - timedelta(seconds=1), + ), ] + sqlite_session.add_all(messages) + sqlite_session.commit() - session = MagicMock() - session.scalars.return_value.all.return_value = messages - - length = get_thread_messages_length("conversation-2", session=session) + length = get_thread_messages_length("conversation-2", session=sqlite_session) assert length == 2 - session.scalars.assert_called_once() From 85189be53dee4533b62adf10a9cdbeddc555b345 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:10:22 +0800 Subject: [PATCH 048/531] chore: bump pymdown-extensions from 10.21.2 to 11.0 in /dify-agent (#39618) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dify-agent/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index d38f141e683..50de0e58c66 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -2704,15 +2704,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.2" +version = "11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" }, ] [[package]] From c9c057fd04e3e0b74f525c26f1d141f516d05700 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 18:12:30 +0900 Subject: [PATCH 049/531] test: use SQLite sessions in rag retrieval (#39049) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- api/core/rag/retrieval/dataset_retrieval.py | 3 + .../test_dataset_retrieval_methods.py | 999 +++++++----------- 2 files changed, 367 insertions(+), 635 deletions(-) diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index b89931f57ff..317e8d98e38 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -2023,6 +2023,8 @@ class DatasetRetrieval: redis_client.zremrangebyscore(key, 0, current_time - 60000) request_count = redis_client.zcard(key) if request_count > knowledge_rate_limit.limit: + # The rate-limit exception is raised after this block, so commit the audit row + # explicitly instead of relying on the Session context, which only closes it. with session_factory.create_session() as session: rate_limit_log = RateLimitLog( tenant_id=tenant_id, @@ -2030,6 +2032,7 @@ class DatasetRetrieval: operation="knowledge", ) session.add(rate_limit_log) + session.commit() raise exc.RateLimitExceededError( "you have reached the knowledge base request rate limit of your subscription." ) diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py index 98413840d00..6768651d6df 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py @@ -1,348 +1,302 @@ -from typing import Any -from unittest.mock import MagicMock, Mock, patch -from uuid import uuid4 +"""SQLite-backed tests for dataset availability, rate limiting, and retrieval orchestration.""" + +from dataclasses import dataclass +from types import SimpleNamespace +from unittest.mock import MagicMock import pytest +from sqlalchemy import Engine, select +from sqlalchemy.orm import Session, sessionmaker +from core.rag.index_processor.constant.index_type import IndexTechniqueType from core.rag.models.document import Document +from core.rag.retrieval import dataset_retrieval as retrieval_module from core.rag.retrieval.dataset_retrieval import DatasetRetrieval from core.workflow.nodes.knowledge_retrieval import exc from core.workflow.nodes.knowledge_retrieval.retrieval import KnowledgeRetrievalRequest -from models.dataset import Dataset - -# ==================== Helper Functions ==================== +from models.dataset import Dataset, DocumentSegment, RateLimitLog +from models.dataset import Document as DatasetDocument +from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus -def create_mock_dataset( - dataset_id: str | None = None, - tenant_id: str | None = None, - provider: str = "dify", - indexing_technique: str = "high_quality", - available_document_count: int = 10, -) -> Mock: - """ - Create a mock Dataset object for testing. +@dataclass(frozen=True) +class RetrievalDatabase: + session_maker: sessionmaker[Session] - Args: - dataset_id: Unique identifier for the dataset - tenant_id: Tenant ID for the dataset - provider: Provider type ("dify" or "external") - indexing_technique: Indexing technique ("high_quality" or "economy") - available_document_count: Number of available documents - Returns: - Mock: A properly configured Dataset mock - """ - dataset = Mock(spec=Dataset) - dataset.id = dataset_id or str(uuid4()) - dataset.tenant_id = tenant_id or str(uuid4()) - dataset.name = "test_dataset" - dataset.provider = provider - dataset.indexing_technique = indexing_technique - dataset.available_document_count = available_document_count - dataset.embedding_model = "text-embedding-ada-002" - dataset.embedding_model_provider = "openai" - dataset.retrieval_model = { - "search_method": "semantic_search", - "reranking_enable": False, - "top_k": 4, - "score_threshold_enabled": False, - } +@pytest.fixture +def retrieval_database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> RetrievalDatabase: + """Bind every retrieval-owned session to a disposable SQLite database.""" + Dataset.metadata.create_all( + sqlite_engine, + tables=[Dataset.__table__, DatasetDocument.__table__, DocumentSegment.__table__, RateLimitLog.__table__], + ) + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr(retrieval_module.session_factory, "create_session", session_maker) + return RetrievalDatabase(session_maker=session_maker) + + +def _persist_dataset( + database: RetrievalDatabase, + *, + dataset_id: str, + tenant_id: str = "tenant-1", + provider: str = "vendor", +) -> Dataset: + dataset = Dataset( + id=dataset_id, + tenant_id=tenant_id, + name=f"Dataset {dataset_id}", + created_by="user-1", + provider=provider, + indexing_technique=IndexTechniqueType.HIGH_QUALITY, + embedding_model="text-embedding-ada-002", + embedding_model_provider="openai", + retrieval_model={ + "search_method": "semantic_search", + "reranking_enable": False, + "top_k": 4, + "score_threshold_enabled": False, + }, + ) + with database.session_maker.begin() as session: + session.add(dataset) return dataset -def create_mock_document( - content: str, - doc_id: str, - score: float = 0.8, - provider: str = "dify", - additional_metadata: dict[str, Any] | None = None, -) -> Document: - """ - Create a mock Document object for testing. +def _persist_document( + database: RetrievalDatabase, + *, + dataset_id: str, + document_id: str, + tenant_id: str = "tenant-1", + indexing_status: IndexingStatus = IndexingStatus.COMPLETED, + enabled: bool = True, + archived: bool = False, +) -> DatasetDocument: + document = DatasetDocument( + id=document_id, + tenant_id=tenant_id, + dataset_id=dataset_id, + position=1, + data_source_type=DataSourceType.UPLOAD_FILE, + batch="batch-1", + name=f"Document {document_id}", + created_from=DocumentCreatedFrom.API, + created_by="user-1", + indexing_status=indexing_status, + enabled=enabled, + archived=archived, + ) + with database.session_maker.begin() as session: + session.add(document) + return document - Args: - content: The text content of the document - doc_id: Unique identifier for the document chunk - score: Relevance score (0.0 to 1.0) - provider: Document provider ("dify" or "external") - additional_metadata: Optional extra metadata fields - Returns: - Document: A properly structured Document object - """ - metadata = { - "doc_id": doc_id, - "document_id": str(uuid4()), - "dataset_id": str(uuid4()), - "score": score, - } +def _persist_segment( + database: RetrievalDatabase, + *, + dataset_id: str, + document_id: str, +) -> DocumentSegment: + segment = DocumentSegment( + tenant_id="tenant-1", + dataset_id=dataset_id, + document_id=document_id, + position=1, + content="Python is great", + word_count=3, + tokens=3, + created_by="user-1", + index_node_id="node-1", + index_node_hash="hash-1", + hit_count=5, + ) + with database.session_maker.begin() as session: + session.add(segment) + return segment - if additional_metadata: - metadata.update(additional_metadata) - return Document( - page_content=content, - metadata=metadata, - provider=provider, +def _persist_available_dataset( + database: RetrievalDatabase, + *, + dataset_id: str = "dataset-1", + document_id: str = "document-1", +) -> tuple[Dataset, DatasetDocument]: + return ( + _persist_dataset(database, dataset_id=dataset_id), + _persist_document(database, dataset_id=dataset_id, document_id=document_id), ) -# ==================== Test _check_knowledge_rate_limit ==================== +def _request( + *, + dataset_ids: list[str], + retrieval_mode: str = "multiple", + metadata_filtering_mode: str = "disabled", +) -> KnowledgeRetrievalRequest: + return KnowledgeRetrievalRequest( + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", + user_from="web", + dataset_ids=dataset_ids, + query="What is Python?", + retrieval_mode=retrieval_mode, + metadata_filtering_mode=metadata_filtering_mode, + top_k=5, + score_threshold=0.7, + reranking_enable=True, + reranking_mode="reranking_model", + reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + ) + + +def _rag_document( + content: str, + doc_id: str, + *, + score: float = 0.8, + provider: str = "dify", + additional_metadata: dict[str, object] | None = None, +) -> Document: + metadata: dict[str, object] = { + "doc_id": doc_id, + "document_id": "document-1", + "dataset_id": "dataset-1", + "score": score, + } + if additional_metadata: + metadata.update(additional_metadata) + return Document(page_content=content, metadata=metadata, provider=provider) + + +def _patch_rate_limit( + monkeypatch: pytest.MonkeyPatch, + *, + enabled: bool, + request_count: int = 0, +) -> MagicMock: + limit = SimpleNamespace(enabled=enabled, limit=100, subscription_plan="professional") + monkeypatch.setattr( + retrieval_module.FeatureService, + "get_knowledge_rate_limit", + MagicMock(return_value=limit), + ) + redis = MagicMock() + redis.zcard.return_value = request_count + monkeypatch.setattr(retrieval_module, "redis_client", redis) + monkeypatch.setattr(retrieval_module.time, "time", lambda: 1234567890) + return redis class TestCheckKnowledgeRateLimit: - """ - Test suite for _check_knowledge_rate_limit method. + def test_rate_limit_disabled_performs_no_redis_or_database_work( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + redis = _patch_rate_limit(monkeypatch, enabled=False) - The _check_knowledge_rate_limit method validates whether a tenant has - exceeded their knowledge retrieval rate limit. This is important for: - - Preventing abuse of the knowledge retrieval system - - Enforcing subscription plan limits - - Tracking usage for billing purposes + DatasetRetrieval()._check_knowledge_rate_limit("tenant-1") - Test Cases: - ============ - 1. Rate limit disabled - no exception raised - 2. Rate limit enabled but not exceeded - no exception raised - 3. Rate limit enabled and exceeded - RateLimitExceededError raised - 4. Redis operations are performed correctly - 5. RateLimitLog is created when limit is exceeded - """ + redis.zadd.assert_not_called() + with retrieval_database.session_maker() as session: + assert session.scalar(select(RateLimitLog)) is None - @patch("core.rag.retrieval.dataset_retrieval.FeatureService") - @patch("core.rag.retrieval.dataset_retrieval.redis_client") - def test_rate_limit_disabled_no_exception(self, mock_redis, mock_feature_service): - """ - Test that when rate limit is disabled, no exception is raised. + def test_rate_limit_enabled_not_exceeded_tracks_request_without_log( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + redis = _patch_rate_limit(monkeypatch, enabled=True, request_count=50) - This test verifies the behavior when the tenant's subscription - does not have rate limiting enabled. + DatasetRetrieval()._check_knowledge_rate_limit("tenant-1") - Verifies: - - FeatureService.get_knowledge_rate_limit is called - - No Redis operations are performed - - No exception is raised - - Retrieval proceeds normally - """ - # Arrange - tenant_id = str(uuid4()) - dataset_retrieval = DatasetRetrieval() - - # Mock rate limit disabled - mock_limit = Mock() - mock_limit.enabled = False - mock_feature_service.get_knowledge_rate_limit.return_value = mock_limit - - # Act & Assert - should not raise any exception - dataset_retrieval._check_knowledge_rate_limit(tenant_id) - - # Verify FeatureService was called - mock_feature_service.get_knowledge_rate_limit.assert_called_once_with(tenant_id) - - # Verify no Redis operations were performed - assert not mock_redis.zadd.called - assert not mock_redis.zremrangebyscore.called - assert not mock_redis.zcard.called - - @patch("core.rag.retrieval.dataset_retrieval.session_factory") - @patch("core.rag.retrieval.dataset_retrieval.FeatureService") - @patch("core.rag.retrieval.dataset_retrieval.redis_client") - @patch("core.rag.retrieval.dataset_retrieval.time") - def test_rate_limit_enabled_not_exceeded(self, mock_time, mock_redis, mock_feature_service, mock_session_factory): - """ - Test that when rate limit is enabled but not exceeded, no exception is raised. - - This test simulates a tenant making requests within their rate limit. - The Redis sorted set stores timestamps of recent requests, and old - requests (older than 60 seconds) are removed. - - Verifies: - - Redis zadd is called to track the request - - Redis zremrangebyscore removes old entries - - Redis zcard returns count within limit - - No exception is raised - """ - # Arrange - tenant_id = str(uuid4()) - dataset_retrieval = DatasetRetrieval() - - # Mock rate limit enabled with limit of 100 requests per minute - mock_limit = Mock() - mock_limit.enabled = True - mock_limit.limit = 100 - mock_limit.subscription_plan = "professional" - mock_feature_service.get_knowledge_rate_limit.return_value = mock_limit - - # Mock time - current_time = 1234567890000 # Current time in milliseconds - mock_time.time.return_value = current_time / 1000 # Return seconds - mock_time.time.__mul__ = lambda self, x: int(self * x) # Multiply to get milliseconds - - # Mock Redis operations - # zcard returns 50 (within limit of 100) - mock_redis.zcard.return_value = 50 - - # Mock session_factory.create_session - mock_session = MagicMock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - mock_session_factory.create_session.return_value.__exit__.return_value = None - - # Act & Assert - should not raise any exception - dataset_retrieval._check_knowledge_rate_limit(tenant_id) - - # Verify Redis operations - expected_key = f"rate_limit_{tenant_id}" - mock_redis.zadd.assert_called_once_with(expected_key, {current_time: current_time}) - mock_redis.zremrangebyscore.assert_called_once_with(expected_key, 0, current_time - 60000) - mock_redis.zcard.assert_called_once_with(expected_key) - - @patch("core.rag.retrieval.dataset_retrieval.session_factory") - @patch("core.rag.retrieval.dataset_retrieval.FeatureService") - @patch("core.rag.retrieval.dataset_retrieval.redis_client") - @patch("core.rag.retrieval.dataset_retrieval.time") - def test_rate_limit_enabled_exceeded_raises_exception( - self, mock_time, mock_redis, mock_feature_service, mock_session_factory - ): - """ - Test that when rate limit is enabled and exceeded, RateLimitExceededError is raised. - - This test simulates a tenant exceeding their rate limit. When the count - of recent requests exceeds the limit, an exception should be raised and - a RateLimitLog should be created. - - Verifies: - - Redis zcard returns count exceeding limit - - RateLimitExceededError is raised with correct message - - RateLimitLog is created in database - - Session operations are performed correctly - """ - # Arrange - tenant_id = str(uuid4()) - dataset_retrieval = DatasetRetrieval() - - # Mock rate limit enabled with limit of 100 requests per minute - mock_limit = Mock() - mock_limit.enabled = True - mock_limit.limit = 100 - mock_limit.subscription_plan = "professional" - mock_feature_service.get_knowledge_rate_limit.return_value = mock_limit - - # Mock time current_time = 1234567890000 - mock_time.time.return_value = current_time / 1000 + redis.zadd.assert_called_once_with("rate_limit_tenant-1", {current_time: current_time}) + redis.zremrangebyscore.assert_called_once_with("rate_limit_tenant-1", 0, current_time - 60000) + with retrieval_database.session_maker() as session: + assert session.scalar(select(RateLimitLog)) is None - # Mock Redis operations - return count exceeding limit - mock_redis.zcard.return_value = 150 # Exceeds limit of 100 + def test_rate_limit_exceeded_commits_audit_log_before_raising( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + _patch_rate_limit(monkeypatch, enabled=True, request_count=150) - # Mock session_factory.create_session - mock_session = MagicMock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - mock_session_factory.create_session.return_value.__exit__.return_value = None + with pytest.raises(exc.RateLimitExceededError, match="knowledge base request rate limit"): + DatasetRetrieval()._check_knowledge_rate_limit("tenant-1") - # Act & Assert - with pytest.raises(exc.RateLimitExceededError) as exc_info: - dataset_retrieval._check_knowledge_rate_limit(tenant_id) - - # Verify exception message - assert "knowledge base request rate limit" in str(exc_info.value) - - # Verify RateLimitLog was created - mock_session.add.assert_called_once() - added_log = mock_session.add.call_args[0][0] - assert added_log.tenant_id == tenant_id - assert added_log.subscription_plan == "professional" - assert added_log.operation == "knowledge" - - -# ==================== Test _get_available_datasets ==================== + with retrieval_database.session_maker() as session: + logs = session.scalars(select(RateLimitLog)).all() + assert len(logs) == 1 + assert logs[0].tenant_id == "tenant-1" + assert logs[0].subscription_plan == "professional" + assert logs[0].operation == "knowledge" class TestGetAvailableDatasets: - """ - Test suite for _get_available_datasets method. + def test_returns_completed_or_external_datasets_with_tenant_scope( + self, + retrieval_database: RetrievalDatabase, + ) -> None: + _persist_available_dataset(retrieval_database, dataset_id="available", document_id="available-doc") + _persist_dataset(retrieval_database, dataset_id="disabled") + _persist_document( + retrieval_database, + dataset_id="disabled", + document_id="disabled-doc", + enabled=False, + ) + _persist_dataset(retrieval_database, dataset_id="archived") + _persist_document( + retrieval_database, + dataset_id="archived", + document_id="archived-doc", + archived=True, + ) + _persist_dataset(retrieval_database, dataset_id="waiting") + _persist_document( + retrieval_database, + dataset_id="waiting", + document_id="waiting-doc", + indexing_status=IndexingStatus.WAITING, + ) + _persist_dataset(retrieval_database, dataset_id="external", provider="external") + _persist_dataset(retrieval_database, dataset_id="other-tenant", tenant_id="tenant-2") + _persist_document( + retrieval_database, + dataset_id="other-tenant", + document_id="other-doc", + tenant_id="tenant-2", + ) - The _get_available_datasets method retrieves datasets that are available - for retrieval. A dataset is considered available if: - - It belongs to the specified tenant - - It's in the list of requested dataset_ids - - It has at least one completed, enabled, non-archived document OR - - It's an external provider dataset + datasets = DatasetRetrieval()._get_available_datasets( + "tenant-1", + ["available", "disabled", "archived", "waiting", "external", "other-tenant"], + ) - Note: Due to SQLAlchemy subquery complexity, full testing is done in - integration tests. Unit tests here verify basic behavior. - """ + assert {dataset.id for dataset in datasets} == {"available", "external"} - def test_method_exists_and_has_correct_signature(self): - """ - Test that the method exists and has the correct signature. + def test_returns_empty_for_vendor_dataset_without_documents( + self, + retrieval_database: RetrievalDatabase, + ) -> None: + _persist_dataset(retrieval_database, dataset_id="empty") - Verifies: - - Method exists on DatasetRetrieval class - - Accepts tenant_id and dataset_ids parameters - """ - # Arrange - dataset_retrieval = DatasetRetrieval() - - # Assert - method exists - assert hasattr(dataset_retrieval, "_get_available_datasets") - # Assert - method is callable - assert callable(dataset_retrieval._get_available_datasets) - - -# ==================== Test knowledge_retrieval ==================== + assert DatasetRetrieval()._get_available_datasets("tenant-1", ["empty"]) == [] class TestDatasetRetrievalKnowledgeRetrieval: - """ - Test suite for knowledge_retrieval method. - - The knowledge_retrieval method is the main entry point for retrieving - knowledge from datasets. It orchestrates the entire retrieval process: - 1. Checks rate limits - 2. Gets available datasets - 3. Applies metadata filtering if enabled - 4. Performs retrieval (single or multiple mode) - 5. Formats and returns results - - Test Cases: - ============ - 1. Single mode retrieval - 2. Multiple mode retrieval - 3. Metadata filtering disabled - 4. Metadata filtering automatic - 5. Metadata filtering manual - 6. External documents handling - 7. Dify documents handling - 8. Empty results handling - 9. Rate limit exceeded - 10. No available datasets - """ - - def test_knowledge_retrieval_single_mode_basic(self): - """ - Test knowledge_retrieval in single retrieval mode - basic check. - - Note: Full single mode testing requires complex model mocking and - is better suited for integration tests. This test verifies the - method accepts single mode requests. - - Verifies: - - Method can accept single mode request - - Request parameters are correctly structured - """ - # Arrange - tenant_id = str(uuid4()) - user_id = str(uuid4()) - app_id = str(uuid4()) - dataset_id = str(uuid4()) - + def test_single_mode_request_shape(self) -> None: request = KnowledgeRetrievalRequest( - tenant_id=tenant_id, - user_id=user_id, - app_id=app_id, + tenant_id="tenant-1", + user_id="user-1", + app_id="app-1", user_from="web", - dataset_ids=[dataset_id], + dataset_ids=["dataset-1"], query="What is Python?", retrieval_mode="single", model_provider="openai", @@ -351,365 +305,140 @@ class TestDatasetRetrievalKnowledgeRetrieval: completion_params={"temperature": 0.7}, ) - # Assert - request is properly structured assert request.retrieval_mode == "single" assert request.model_provider == "openai" assert request.model_name == "gpt-4" - assert request.model_mode == "chat" - @patch("core.rag.retrieval.dataset_retrieval.DataPostProcessor") - @patch("core.rag.retrieval.dataset_retrieval.session_factory") - def test_knowledge_retrieval_multiple_mode(self, mock_session_factory, mock_data_processor): - """ - Test knowledge_retrieval in multiple retrieval mode. - - In multiple mode, retrieval is performed across all datasets and - results are combined and reranked. - - Verifies: - - Rate limit is checked - - Available datasets are retrieved - - Multiple retrieval is performed - - Results are combined and reranked - - Results are formatted correctly - """ - # Arrange - tenant_id = str(uuid4()) - user_id = str(uuid4()) - app_id = str(uuid4()) - dataset_id1 = str(uuid4()) - dataset_id2 = str(uuid4()) - - request = KnowledgeRetrievalRequest( - tenant_id=tenant_id, - user_id=user_id, - app_id=app_id, - user_from="web", - dataset_ids=[dataset_id1, dataset_id2], - query="What is Python?", - retrieval_mode="multiple", - top_k=5, - score_threshold=0.7, - reranking_enable=True, - reranking_mode="reranking_model", - reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + def test_multiple_mode_formats_persisted_dataset_and_document( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + dataset, document = _persist_available_dataset(retrieval_database) + segment = _persist_segment(retrieval_database, dataset_id=dataset.id, document_id=document.id) + retrieval = DatasetRetrieval() + monkeypatch.setattr(retrieval, "_check_knowledge_rate_limit", MagicMock()) + monkeypatch.setattr(retrieval, "multiple_retrieve", MagicMock(return_value=[_rag_document("Python", "node-1")])) + record = SimpleNamespace(segment=segment, score=0.9, child_chunks=[], summary=None, files=None) + monkeypatch.setattr( + retrieval_module.RetrievalService, + "format_retrieval_documents", + MagicMock(return_value=[record]), ) + grant_access = MagicMock() + monkeypatch.setattr(retrieval_module, "grant_retriever_segment_access", grant_access) - dataset_retrieval = DatasetRetrieval() + with retrieval_database.session_maker() as caller_session: + result = retrieval.knowledge_retrieval(caller_session, _request(dataset_ids=[dataset.id])) - # Mock _check_knowledge_rate_limit - with patch.object(dataset_retrieval, "_check_knowledge_rate_limit"): - # Mock _get_available_datasets - mock_dataset1 = create_mock_dataset(dataset_id=dataset_id1, tenant_id=tenant_id) - mock_dataset2 = create_mock_dataset(dataset_id=dataset_id2, tenant_id=tenant_id) - with patch.object( - dataset_retrieval, "_get_available_datasets", return_value=[mock_dataset1, mock_dataset2] - ): - # Mock get_metadata_filter_condition - with patch.object(dataset_retrieval, "get_metadata_filter_condition", return_value=(None, None)): - # Mock multiple_retrieve to return documents - doc1 = create_mock_document("Python is great", "doc1", score=0.9) - doc2 = create_mock_document("Python is awesome", "doc2", score=0.8) - with patch.object( - dataset_retrieval, "multiple_retrieve", return_value=[doc1, doc2] - ) as mock_multiple_retrieve: - # Mock format_retrieval_documents - mock_record = Mock() - mock_record.segment = Mock() - mock_record.segment.dataset_id = dataset_id1 - mock_record.segment.document_id = str(uuid4()) - mock_record.segment.index_node_hash = "hash123" - mock_record.segment.hit_count = 5 - mock_record.segment.word_count = 100 - mock_record.segment.position = 1 - mock_record.segment.get_sign_content.return_value = "Python is great" - mock_record.segment.answer = None - mock_record.score = 0.9 - mock_record.child_chunks = [] - mock_record.summary = None - mock_record.files = None + assert len(result) == 1 + assert result[0].metadata.dataset_id == dataset.id + assert result[0].metadata.document_id == document.id + assert result[0].title == document.name + grant_access.assert_called_once_with([segment.id]) - mock_retrieval_service = Mock() - mock_retrieval_service.format_retrieval_documents.return_value = [mock_record] + def test_metadata_filtering_disabled_skips_filter_builder( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + _persist_available_dataset(retrieval_database) + retrieval = DatasetRetrieval() + monkeypatch.setattr(retrieval, "_check_knowledge_rate_limit", MagicMock()) + metadata_filter = MagicMock(return_value=(None, None)) + monkeypatch.setattr(retrieval, "get_metadata_filter_condition", metadata_filter) + monkeypatch.setattr(retrieval, "multiple_retrieve", MagicMock(return_value=[])) - with patch( - "core.rag.retrieval.dataset_retrieval.RetrievalService", - return_value=mock_retrieval_service, - ): - # Mock database queries - mock_session = MagicMock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - mock_session_factory.create_session.return_value.__exit__.return_value = None + with retrieval_database.session_maker() as caller_session: + result = retrieval.knowledge_retrieval(caller_session, _request(dataset_ids=["dataset-1"])) - mock_dataset_from_db = Mock() - mock_dataset_from_db.id = dataset_id1 - mock_dataset_from_db.name = "test_dataset" + assert result == [] + metadata_filter.assert_not_called() - mock_document = Mock() - mock_document.id = str(uuid4()) - mock_document.name = "test_doc" - mock_document.data_source_type = "upload_file" - mock_document.doc_metadata = {} - - mock_datasets = MagicMock() - mock_datasets.all.return_value = [mock_dataset_from_db] - mock_documents = MagicMock() - mock_documents.all.return_value = [mock_document] - mock_session.scalars.side_effect = [mock_datasets, mock_documents] - - # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) - - # Assert - assert isinstance(result, list) - mock_multiple_retrieve.assert_called_once() - - def test_knowledge_retrieval_metadata_filtering_disabled(self): - """ - Test knowledge_retrieval with metadata filtering disabled. - - When metadata filtering is disabled, get_metadata_filter_condition is - NOT called (the method checks metadata_filtering_mode != "disabled"). - - Verifies: - - get_metadata_filter_condition is NOT called when mode is "disabled" - - Retrieval proceeds without metadata filters - """ - # Arrange - tenant_id = str(uuid4()) - user_id = str(uuid4()) - app_id = str(uuid4()) - dataset_id = str(uuid4()) - - request = KnowledgeRetrievalRequest( - tenant_id=tenant_id, - user_id=user_id, - app_id=app_id, - user_from="web", - dataset_ids=[dataset_id], - query="What is Python?", - retrieval_mode="multiple", - metadata_filtering_mode="disabled", - top_k=5, + def test_external_documents_are_formatted_without_database_document( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + _persist_dataset(retrieval_database, dataset_id="external", provider="external") + retrieval = DatasetRetrieval() + monkeypatch.setattr(retrieval, "_check_knowledge_rate_limit", MagicMock()) + external_document = _rag_document( + "External knowledge", + "external-node", + score=0.9, + provider="external", + additional_metadata={ + "dataset_id": "external", + "dataset_name": "External Dataset", + "document_id": "external-document", + "title": "External Document", + }, ) + monkeypatch.setattr(retrieval, "multiple_retrieve", MagicMock(return_value=[external_document])) - dataset_retrieval = DatasetRetrieval() + with retrieval_database.session_maker() as caller_session: + result = retrieval.knowledge_retrieval(caller_session, _request(dataset_ids=["external"])) - # Mock dependencies - with patch.object(dataset_retrieval, "_check_knowledge_rate_limit"): - mock_dataset = create_mock_dataset(dataset_id=dataset_id, tenant_id=tenant_id) - with patch.object(dataset_retrieval, "_get_available_datasets", return_value=[mock_dataset]): - # Mock get_metadata_filter_condition - should NOT be called when disabled - with patch.object( - dataset_retrieval, - "get_metadata_filter_condition", - return_value=(None, None), - ) as mock_get_metadata: - with patch.object(dataset_retrieval, "multiple_retrieve", return_value=[]): - # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) + assert len(result) == 1 + assert result[0].metadata.data_source_type == "external" + assert result[0].metadata.dataset_id == "external" - # Assert - assert isinstance(result, list) - # get_metadata_filter_condition should NOT be called when mode is "disabled" - mock_get_metadata.assert_not_called() + def test_empty_retrieval_results_return_empty_list( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + _persist_available_dataset(retrieval_database) + retrieval = DatasetRetrieval() + monkeypatch.setattr(retrieval, "_check_knowledge_rate_limit", MagicMock()) + monkeypatch.setattr(retrieval, "multiple_retrieve", MagicMock(return_value=[])) - def test_knowledge_retrieval_with_external_documents(self): - """ - Test knowledge_retrieval with external documents. + with retrieval_database.session_maker() as caller_session: + result = retrieval.knowledge_retrieval(caller_session, _request(dataset_ids=["dataset-1"])) - External documents come from external knowledge bases and should - be formatted differently than Dify documents. + assert result == [] - Verifies: - - External documents are handled correctly - - Provider is set to "external" - - Metadata includes external-specific fields - """ - # Arrange - tenant_id = str(uuid4()) - user_id = str(uuid4()) - app_id = str(uuid4()) - dataset_id = str(uuid4()) - - request = KnowledgeRetrievalRequest( - tenant_id=tenant_id, - user_id=user_id, - app_id=app_id, - user_from="web", - dataset_ids=[dataset_id], - query="What is Python?", - retrieval_mode="multiple", - top_k=5, - ) - - dataset_retrieval = DatasetRetrieval() - - # Mock dependencies - with patch.object(dataset_retrieval, "_check_knowledge_rate_limit"): - mock_dataset = create_mock_dataset(dataset_id=dataset_id, tenant_id=tenant_id, provider="external") - with patch.object(dataset_retrieval, "_get_available_datasets", return_value=[mock_dataset]): - with patch.object(dataset_retrieval, "get_metadata_filter_condition", return_value=(None, None)): - # Create external document - external_doc = create_mock_document( - "External knowledge", - "doc1", - score=0.9, - provider="external", - additional_metadata={ - "dataset_id": dataset_id, - "dataset_name": "external_kb", - "document_id": "ext_doc1", - "title": "External Document", - }, - ) - with patch.object(dataset_retrieval, "multiple_retrieve", return_value=[external_doc]): - # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) - - # Assert - assert isinstance(result, list) - if result: - assert result[0].metadata.data_source_type == "external" - - def test_knowledge_retrieval_empty_results(self): - """ - Test knowledge_retrieval when no documents are found. - - Verifies: - - Empty list is returned - - No errors are raised - - All dependencies are still called - """ - # Arrange - tenant_id = str(uuid4()) - user_id = str(uuid4()) - app_id = str(uuid4()) - dataset_id = str(uuid4()) - - request = KnowledgeRetrievalRequest( - tenant_id=tenant_id, - user_id=user_id, - app_id=app_id, - user_from="web", - dataset_ids=[dataset_id], - query="What is Python?", - retrieval_mode="multiple", - top_k=5, - ) - - dataset_retrieval = DatasetRetrieval() - - # Mock dependencies - with patch.object(dataset_retrieval, "_check_knowledge_rate_limit"): - mock_dataset = create_mock_dataset(dataset_id=dataset_id, tenant_id=tenant_id) - with patch.object(dataset_retrieval, "_get_available_datasets", return_value=[mock_dataset]): - with patch.object(dataset_retrieval, "get_metadata_filter_condition", return_value=(None, None)): - # Mock multiple_retrieve to return empty list - with patch.object(dataset_retrieval, "multiple_retrieve", return_value=[]): - # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) - - # Assert - assert result == [] - - def test_knowledge_retrieval_rate_limit_exceeded(self): - """ - Test knowledge_retrieval when rate limit is exceeded. - - Verifies: - - RateLimitExceededError is raised - - No further processing occurs - """ - # Arrange - tenant_id = str(uuid4()) - user_id = str(uuid4()) - app_id = str(uuid4()) - dataset_id = str(uuid4()) - - request = KnowledgeRetrievalRequest( - tenant_id=tenant_id, - user_id=user_id, - app_id=app_id, - user_from="web", - dataset_ids=[dataset_id], - query="What is Python?", - retrieval_mode="multiple", - top_k=5, - ) - - dataset_retrieval = DatasetRetrieval() - - # Mock _check_knowledge_rate_limit to raise exception - with patch.object( - dataset_retrieval, + def test_rate_limit_exception_stops_retrieval( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + retrieval = DatasetRetrieval() + monkeypatch.setattr( + retrieval, "_check_knowledge_rate_limit", - side_effect=exc.RateLimitExceededError("Rate limit exceeded"), - ): - # Act & Assert - with pytest.raises(exc.RateLimitExceededError): - dataset_retrieval.knowledge_retrieval(MagicMock(), request) - - def test_knowledge_retrieval_no_available_datasets(self): - """ - Test knowledge_retrieval when no datasets are available. - - Verifies: - - Empty list is returned - - No retrieval is attempted - """ - # Arrange - tenant_id = str(uuid4()) - user_id = str(uuid4()) - app_id = str(uuid4()) - dataset_id = str(uuid4()) - - request = KnowledgeRetrievalRequest( - tenant_id=tenant_id, - user_id=user_id, - app_id=app_id, - user_from="web", - dataset_ids=[dataset_id], - query="What is Python?", - retrieval_mode="multiple", - top_k=5, + MagicMock(side_effect=exc.RateLimitExceededError("Rate limit exceeded")), ) - dataset_retrieval = DatasetRetrieval() + with retrieval_database.session_maker() as caller_session: + with pytest.raises(exc.RateLimitExceededError): + retrieval.knowledge_retrieval(caller_session, _request(dataset_ids=["dataset-1"])) - # Mock dependencies - with patch.object(dataset_retrieval, "_check_knowledge_rate_limit"): - # Mock _get_available_datasets to return empty list - with patch.object(dataset_retrieval, "_get_available_datasets", return_value=[]): - # Act - result = dataset_retrieval.knowledge_retrieval(MagicMock(), request) + def test_no_available_datasets_skips_retrieval( + self, + monkeypatch: pytest.MonkeyPatch, + retrieval_database: RetrievalDatabase, + ) -> None: + _persist_dataset(retrieval_database, dataset_id="empty") + retrieval = DatasetRetrieval() + monkeypatch.setattr(retrieval, "_check_knowledge_rate_limit", MagicMock()) + multiple_retrieve = MagicMock() + monkeypatch.setattr(retrieval, "multiple_retrieve", multiple_retrieve) - # Assert - assert result == [] + with retrieval_database.session_maker() as caller_session: + result = retrieval.knowledge_retrieval(caller_session, _request(dataset_ids=["empty"])) - def test_knowledge_retrieval_handles_multiple_documents_with_different_scores(self): - """ - Test that knowledge_retrieval processes multiple documents with different scores. + assert result == [] + multiple_retrieve.assert_not_called() - Note: Full sorting and position testing requires complex SQLAlchemy mocking - which is better suited for integration tests. This test verifies documents - with different scores can be created and have their metadata. + def test_document_scores_sort_descending(self) -> None: + documents = [ + _rag_document("Low", "doc1", score=0.6), + _rag_document("High", "doc2", score=0.95), + _rag_document("Medium", "doc3", score=0.8), + ] - Verifies: - - Documents can be created with different scores - - Score metadata is properly set - """ - # Create documents with different scores - doc1 = create_mock_document("Low score", "doc1", score=0.6) - doc2 = create_mock_document("High score", "doc2", score=0.95) - doc3 = create_mock_document("Medium score", "doc3", score=0.8) + sorted_documents = sorted(documents, key=lambda document: document.metadata["score"], reverse=True) - # Assert - each document has the correct score - assert doc1.metadata["score"] == 0.6 - assert doc2.metadata["score"] == 0.95 - assert doc3.metadata["score"] == 0.8 - - # Assert - documents are correctly sorted (not the retrieval result, just the list) - unsorted = [doc1, doc2, doc3] - sorted_docs = sorted(unsorted, key=lambda d: d.metadata["score"], reverse=True) - assert [d.metadata["score"] for d in sorted_docs] == [0.95, 0.8, 0.6] + assert [document.metadata["score"] for document in sorted_documents] == [0.95, 0.8, 0.6] From d9c038daf27618e5cfb3b7d8c3ca5cadf13db197 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:24:58 +0800 Subject: [PATCH 050/531] chore(deps): bump the storage group across 1 directory with 3 updates (#39604) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 6 +++--- api/uv.lock | 30 +++++++++++++++--------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 53ed8927292..02782d4eec0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,7 +6,7 @@ requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.4.0,<7.0.0", - "boto3>=1.43.46,<2.0.0", + "boto3>=1.43.56,<2.0.0", "celery>=5.6.3,<6.0.0", "croniter>=6.2.2,<7.0.0", "dify-agent", @@ -193,10 +193,10 @@ dev = [ ############################################################ storage = [ "azure-storage-blob>=12.30.0,<13.0.0", - "bce-python-sdk==0.9.72", + "bce-python-sdk==0.9.76", "cos-python-sdk-v5>=1.9.44,<2.0.0", "esdk-obs-python>=3.26.6,<4.0.0", - "google-cloud-storage>=3.12.1,<4.0.0", + "google-cloud-storage>=3.13.0,<4.0.0", "opendal==0.46.0", "oss2>=2.19.1,<3.0.0", "supabase>=2.31.0,<3.0.0", diff --git a/api/uv.lock b/api/uv.lock index 3bf03782562..2333a5b710f 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "bce-python-sdk" -version = "0.9.72" +version = "0.9.76" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crc32c" }, @@ -483,9 +483,9 @@ dependencies = [ { name = "pycryptodome" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/bb/1ccb8b28bfa0802356f8588e479adb61cfd2268b37fabc6c8a805d645cd5/bce_python_sdk-0.9.72.tar.gz", hash = "sha256:d9db568698792d74db4245252d98776eae8c9c5225fc0ba86548dfc52d478fcc", size = 302207, upload-time = "2026-06-08T12:10:32.326Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/ed/b41906366c0e1e3a1a883e0f1bcfc927403d9697d74b1eb7e89502870218/bce_python_sdk-0.9.76.tar.gz", hash = "sha256:01c630ce8dcbf8be0563d65f18b5201eaac6bd954b3dc776040aec268f81b6ff", size = 317399, upload-time = "2026-07-24T05:12:57.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/3a/f84b025ff6c8ec8fe222430cf9201b513dd11ada744417649a64ad295b6d/bce_python_sdk-0.9.72-py3-none-any.whl", hash = "sha256:54a0c121134d6f183f6013d9b33dbf5b6678b0815b6d09616bbceed0202dd797", size = 417800, upload-time = "2026-06-08T12:10:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/b9/06/4d4a26c3dfcdb29599f563329b87eefe55b02b3c2b9d8f8c3aeaa38a33a6/bce_python_sdk-0.9.76-py3-none-any.whl", hash = "sha256:e629181d060f4ed8f29749f47139bf08f1f9b0d8b15daedc956900785587e8d8", size = 435179, upload-time = "2026-07-24T05:12:55.064Z" }, ] [[package]] @@ -598,16 +598,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.46" +version = "1.43.56" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/e7/976bf3dfe0aa5d7f31bec2f2cf57c79641620c910a39bc843a237aa9592d/boto3-1.43.46.tar.gz", hash = "sha256:66c0d943b049a46a492ec4ec2ebe73c930b1842c7137bee83aad6d93e95d4d96", size = 112654, upload-time = "2026-07-10T19:32:12.498Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/05/23e1aa8c9e4b0399a61e7fd65c4f9cc0625121f24760e37471f776404abb/boto3-1.43.56.tar.gz", hash = "sha256:57c90df9fb026f2e6ae22530861198130203733c5c9ec4e5cca3a4037f5a8db4", size = 112673, upload-time = "2026-07-24T19:31:48.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl", hash = "sha256:69453e2c1bcb9fd9806527ab99950cacfc2826cb0dce9a3a0414d19270c06c3c", size = 140031, upload-time = "2026-07-10T19:32:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/b8/57/3a960c9f581c00f2a591901b46e035ff79ab3956d16607f12306b3b8d483/boto3-1.43.56-py3-none-any.whl", hash = "sha256:feb699d4ab241ef5c1b80bb58277be2aaad365cd4b672d7817e0bc59ee45131b", size = 140026, upload-time = "2026-07-24T19:31:47.155Z" }, ] [[package]] @@ -630,16 +630,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.43.46" +version = "1.43.56" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/cc/7f84a5d3071fe878380e9f610ab36ca87b8cbbc4aa81ba2727f90e1f3ea3/botocore-1.43.56.tar.gz", hash = "sha256:6c01f85f0ff9863076f4c761e74ee3aa96c5ccc1ad09fc1efd62ef8f2d22bf57", size = 15733117, upload-time = "2026-07-24T19:31:38.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/86fe9e659e9699f62f8dd5ecd8c6725474334b23cab8aa71d82b5f56f1a4/botocore-1.43.56-py3-none-any.whl", hash = "sha256:aafc741f1b10f6fd63253eaf6ea029680c1ff436d87e1b8969d62aefa0c76976", size = 15418773, upload-time = "2026-07-24T19:31:34.758Z" }, ] [[package]] @@ -1621,7 +1621,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = "==0.9.44" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.4.0,<7.0.0" }, - { name = "boto3", specifier = ">=1.43.46,<2.0.0" }, + { name = "boto3", specifier = ">=1.43.56,<2.0.0" }, { name = "celery", specifier = ">=5.6.3,<6.0.0" }, { name = "croniter", specifier = ">=6.2.2,<7.0.0" }, { name = "dify-agent", editable = "../dify-agent" }, @@ -1727,10 +1727,10 @@ dev = [ ] storage = [ { name = "azure-storage-blob", specifier = ">=12.30.0,<13.0.0" }, - { name = "bce-python-sdk", specifier = "==0.9.72" }, + { name = "bce-python-sdk", specifier = "==0.9.76" }, { name = "cos-python-sdk-v5", specifier = ">=1.9.44,<2.0.0" }, { name = "esdk-obs-python", specifier = ">=3.26.6,<4.0.0" }, - { name = "google-cloud-storage", specifier = ">=3.12.1,<4.0.0" }, + { name = "google-cloud-storage", specifier = ">=3.13.0,<4.0.0" }, { name = "opendal", specifier = "==0.46.0" }, { name = "oss2", specifier = ">=2.19.1,<3.0.0" }, { name = "supabase", specifier = ">=2.31.0,<3.0.0" }, @@ -2891,7 +2891,7 @@ wheels = [ [[package]] name = "google-cloud-storage" -version = "3.12.1" +version = "3.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -2901,9 +2901,9 @@ dependencies = [ { name = "google-resumable-media" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/ac/60b4cb0a6c8c6bb7cedb8971ba5e34a94096acf76e2cc242bcf1e6fc5c49/google_cloud_storage-3.12.1.tar.gz", hash = "sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b", size = 17339353, upload-time = "2026-07-08T17:03:59.142Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/25/355ed97c1723c787dfaa888808d55db18371f82c38ff862357b1e902cd19/google_cloud_storage-3.13.0.tar.gz", hash = "sha256:d11d8706ea1520fba0f21043bcb7897caf7015d76ce1ad9a4f60237e4d7a9f6c", size = 17340960, upload-time = "2026-07-13T19:10:07.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/6e/ca176e95bafac0fe7befeee7e0420e686de147571cd2908e308c5fe71bda/google_cloud_storage-3.12.1-py3-none-any.whl", hash = "sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb", size = 340845, upload-time = "2026-07-08T17:03:31.418Z" }, + { url = "https://files.pythonhosted.org/packages/81/e8/b3678a0931ee7d4b3fdaf0813e6206d66e0922b2c26d912f308728b5b95a/google_cloud_storage-3.13.0-py3-none-any.whl", hash = "sha256:648af3ef8a6acc674e1359d3c920c67eb89a7a5ab66b336bd3ac43fed6b5ab84", size = 341428, upload-time = "2026-07-13T19:09:52.39Z" }, ] [[package]] From 8de3b4d03348c45225aa0c5228740f60b55a42e9 Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 27 Jul 2026 17:32:52 +0800 Subject: [PATCH 051/531] fix: prevent the agent build draft UI from flashing during apply (#39642) --- .../configure/__tests__/page.spec.tsx | 91 ++++++++++++++++++- .../use-agent-configure-build-draft.ts | 25 +++-- 2 files changed, 105 insertions(+), 11 deletions(-) diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx index 4c971ea3cdb..08152cbd89e 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/page.spec.tsx @@ -356,12 +356,14 @@ vi.mock('../components/orchestrate/build-draft-bar', () => ({ changeSummary?: unknown changesCount: number disabled?: boolean + isApplying?: boolean onApply: () => void onDiscard: () => void }) => (
{`changes:${props.changesCount}`} -
diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx index 6675b4da62f..c8f7486a751 100644 --- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx +++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/update.tsx @@ -177,11 +177,12 @@ const AddExtractParameter: FC = ({ type, payload, onSave, onCancel }) => handleParamChange('name')(e.target.value)} - placeholder={ - t(($) => $[`${i18nPrefix}.addExtractParameterContent.namePlaceholder`], { + placeholder={t( + ($) => $[`${i18nPrefix}.addExtractParameterContent.namePlaceholder`], + { ns: 'workflow', - })! - } + }, + )!} /> = ({ type, payload, onSave, onCancel }) => )} value={param.description} onValueChange={(value) => handleParamChange('description')(value)} - placeholder={ - t( - ($) => $[`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`], - { ns: 'workflow' }, - )! - } + placeholder={t( + ($) => $[`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`], + { ns: 'workflow' }, + )!} /> Date: Mon, 27 Jul 2026 18:37:45 +0800 Subject: [PATCH 057/531] fix(workflow): preserve latest collaboration session (#39646) --- ...laboration-manager.runtime-loading.spec.ts | 84 ++++++++++++++++++- ...n-manager.socket-and-subscriptions.spec.ts | 6 +- .../core/collaboration-manager.ts | 45 ++++++++-- 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts index 7fdefbb6fc7..58e91af43ad 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.runtime-loading.spec.ts @@ -16,6 +16,15 @@ const createMockSocket = (): Socket => off: vi.fn(), }) as unknown as Socket +const createDeferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + + return { promise, resolve } +} + const loadCollaborationModules = async () => { const [{ CollaborationManager }, { webSocketClient }] = await Promise.all([ import('../collaboration-manager'), @@ -40,10 +49,11 @@ describe('CollaborationManager CRDT runtime loading', () => { expect(manager.isConnected()).toBe(false) }) - it('does not create connection state when the runtime fails to load', async () => { + it('does not create connection state when the runtime fails to load and allows a retry', async () => { const { CollaborationManager, webSocketClient } = await loadCollaborationModules() const manager = new CollaborationManager() const runtimeError = new Error('runtime-load-failed') + const retryError = new Error('runtime-retry-failed') const loadRuntimeSpy = vi .spyOn( manager as unknown as { @@ -51,7 +61,8 @@ describe('CollaborationManager CRDT runtime loading', () => { }, 'loadCrdtRuntime', ) - .mockRejectedValue(runtimeError) + .mockRejectedValueOnce(runtimeError) + .mockRejectedValueOnce(retryError) const connectSpy = vi.spyOn(webSocketClient, 'connect') await expect(manager.connect('app-runtime-failure')).rejects.toBe(runtimeError) @@ -59,11 +70,22 @@ describe('CollaborationManager CRDT runtime loading', () => { expect(loadRuntimeSpy).toHaveBeenCalledTimes(1) expect(connectSpy).not.toHaveBeenCalled() expect(manager.isConnected()).toBe(false) + + await expect(manager.connect('app-runtime-failure')).rejects.toBe(retryError) + + expect(loadRuntimeSpy).toHaveBeenCalledTimes(2) + expect(connectSpy).not.toHaveBeenCalled() }) it('initializes one session for concurrent consumers of the same app', async () => { const { CollaborationManager, webSocketClient } = await loadCollaborationModules() const manager = new CollaborationManager() + const loadRuntimeSpy = vi.spyOn( + manager as unknown as { + loadCrdtRuntime: () => Promise<(typeof import('../crdt-runtime'))['crdtRuntime']> + }, + 'loadCrdtRuntime', + ) const socket = createMockSocket() const connectSpy = vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket) const disconnectSpy = vi @@ -76,6 +98,7 @@ describe('CollaborationManager CRDT runtime loading', () => { ]) expect(firstConnectionId).not.toBe(secondConnectionId) + expect(loadRuntimeSpy).toHaveBeenCalledTimes(1) expect(connectSpy).toHaveBeenCalledTimes(1) expect(loroModuleState.evaluations).toBe(1) @@ -85,4 +108,61 @@ describe('CollaborationManager CRDT runtime loading', () => { manager.disconnect(secondConnectionId) expect(disconnectSpy).toHaveBeenCalledWith('app-concurrent') }) + + it('keeps the latest app session when the app changes during runtime loading', async () => { + const { CollaborationManager, webSocketClient } = await loadCollaborationModules() + const { crdtRuntime } = await import('../crdt-runtime') + const manager = new CollaborationManager() + const runtime = createDeferred() + vi.spyOn( + manager as unknown as { + loadCrdtRuntime: () => Promise + }, + 'loadCrdtRuntime', + ).mockReturnValue(runtime.promise) + const socket = createMockSocket() + const connectSpy = vi.spyOn(webSocketClient, 'connect').mockReturnValue(socket) + const disconnectSpy = vi + .spyOn(webSocketClient, 'disconnect') + .mockImplementation(() => undefined) + + const firstConnection = manager.connect('app-first') + const secondConnection = manager.connect('app-second') + + runtime.resolve(crdtRuntime) + const [firstConnectionId, secondConnectionId] = await Promise.all([ + firstConnection, + secondConnection, + ]) + + expect(connectSpy).toHaveBeenCalledTimes(1) + expect(connectSpy).toHaveBeenCalledWith('app-second') + + manager.disconnect(firstConnectionId) + expect(disconnectSpy).not.toHaveBeenCalled() + + manager.disconnect(secondConnectionId) + expect(disconnectSpy).toHaveBeenCalledWith('app-second') + }) + + it('does not initialize a pending session after the manager is destroyed', async () => { + const { CollaborationManager, webSocketClient } = await loadCollaborationModules() + const { crdtRuntime } = await import('../crdt-runtime') + const manager = new CollaborationManager() + const runtime = createDeferred() + vi.spyOn( + manager as unknown as { + loadCrdtRuntime: () => Promise + }, + 'loadCrdtRuntime', + ).mockReturnValue(runtime.promise) + const connectSpy = vi.spyOn(webSocketClient, 'connect') + + const connection = manager.connect('app-destroyed') + manager.destroy() + runtime.resolve(crdtRuntime) + + await connection + expect(connectSpy).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts index 5ecae6c355c..e4303aadbea 100644 --- a/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts +++ b/web/app/components/workflow/collaboration/core/__tests__/collaboration-manager.socket-and-subscriptions.spec.ts @@ -907,13 +907,17 @@ describe('CollaborationManager socket and subscription behavior', () => { expect(secondConnectionId).toBeTruthy() expect(disconnectSpy).not.toHaveBeenCalled() - await manager.connect('app-2', reactFlowStore) + const thirdConnectionId = await manager.connect('app-2', reactFlowStore) expect(disconnectSpy).toHaveBeenCalledWith('app-1') expect(internals.currentAppId).toBe('app-2') internals.isLeader = true manager.disconnect(secondConnectionId) manager.disconnect(firstConnectionId) + expect(disconnectSpy).not.toHaveBeenCalledWith('app-2') + expect(internals.currentAppId).toBe('app-2') + + manager.disconnect(thirdConnectionId) expect(disconnectSpy).toHaveBeenCalledWith('app-2') expect(eventEmitSpy).toHaveBeenCalledWith('leaderChange', false) expect(internals.currentAppId).toBeNull() diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index 02bbd7b7e13..e986c22180a 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -156,6 +156,9 @@ const toUint8Array = (value: unknown): Uint8Array | null => { export class CollaborationManager { private crdtRuntime: CrdtRuntime | null = null + private crdtRuntimePromise: Promise | null = null + private targetAppId: string | null = null + private connectGeneration = 0 private doc: LoroDoc | null = null private undoManager: UndoManager | null = null private provider: CRDTProvider | null = null @@ -538,6 +541,20 @@ export class CollaborationManager { return crdtRuntime } + private async ensureCrdtRuntime(): Promise { + if (this.crdtRuntime) return + + const runtimePromise = this.crdtRuntimePromise ?? this.loadCrdtRuntime() + this.crdtRuntimePromise = runtimePromise + + try { + this.crdtRuntime = await runtimePromise + } catch (error) { + if (this.crdtRuntimePromise === runtimePromise) this.crdtRuntimePromise = null + throw error + } + } + private getCrdtRuntime(): CrdtRuntime { if (!this.crdtRuntime) throw new Error('CRDT runtime not initialized') return this.crdtRuntime @@ -639,21 +656,31 @@ export class CollaborationManager { } async connect(appId: string, reactFlowStore?: ReactFlowStore): Promise { - this.crdtRuntime ??= await this.loadCrdtRuntime() - const connectionId = Math.random().toString(36).substring(2, 11) + if (this.targetAppId !== appId) { + this.targetAppId = appId + this.connectGeneration += 1 + } + const connectGeneration = this.connectGeneration - this.activeConnections.add(connectionId) + if (!this.crdtRuntime) await this.ensureCrdtRuntime() + + if (connectGeneration !== this.connectGeneration || this.targetAppId !== appId) + return connectionId if (this.currentAppId === appId && this.doc) { // Already connected to the same app, only update store if provided and we don't have one if (reactFlowStore && !this.reactFlowStore) this.reactFlowStore = reactFlowStore + this.activeConnections.add(connectionId) return connectionId } // Only disconnect if switching to a different app - if (this.currentAppId && this.currentAppId !== appId) this.forceDisconnect() + if (this.currentAppId && this.currentAppId !== appId) + this.forceDisconnect({ preserveConnectIntent: true }) + + this.activeConnections.add(connectionId) this.hasEstablishedConnection = false this.currentAppId = appId @@ -685,7 +712,7 @@ export class CollaborationManager { } disconnect = (connectionId?: string): void => { - if (connectionId) this.activeConnections.delete(connectionId) + if (connectionId && !this.activeConnections.delete(connectionId)) return // Only disconnect when no more connections if (this.activeConnections.size === 0) this.forceDisconnect() @@ -699,7 +726,9 @@ export class CollaborationManager { this.pendingWorkflowSyncRequests.clear() } - private forceDisconnect = (): void => { + private forceDisconnect = ({ + preserveConnectIntent = false, + }: { preserveConnectIntent?: boolean } = {}): void => { if (this.currentAppId) webSocketClient.disconnect(this.currentAppId) this.clearInitialSyncRetry() @@ -740,6 +769,10 @@ export class CollaborationManager { if (wasLeader) this.eventEmitter.emit('leaderChange', false) this.activeConnections.clear() + if (!preserveConnectIntent) { + this.targetAppId = null + this.connectGeneration += 1 + } this.eventEmitter.removeAllListeners() } From 75016e8bfe43bd02301d75f9d56975f187cad8f7 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 19:41:17 +0900 Subject: [PATCH 058/531] test: use SQLite sessions in core ops (#39072) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- .../core/ops/test_lookup_helpers.py | 513 ++++++++++-------- 1 file changed, 277 insertions(+), 236 deletions(-) diff --git a/api/tests/unit_tests/core/ops/test_lookup_helpers.py b/api/tests/unit_tests/core/ops/test_lookup_helpers.py index 86aa68643da..90c2127b6ad 100644 --- a/api/tests/unit_tests/core/ops/test_lookup_helpers.py +++ b/api/tests/unit_tests/core/ops/test_lookup_helpers.py @@ -7,36 +7,212 @@ Covers: - TraceTask._get_user_id_from_metadata """ -from unittest.mock import MagicMock, patch +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from unittest.mock import PropertyMock, patch import pytest +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session + +from core.tools.entities.tool_entities import ApiProviderSchemaType +from extensions.ext_database import db +from graphon.model_runtime.entities.model_entities import ModelType +from models.account import Tenant +from models.base import TypeBase +from models.model import App, AppMode, IconType +from models.provider import Provider, ProviderCredential, ProviderModel, ProviderModelCredential, ProviderType +from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def _make_db_and_session_patches(scalar_side_effect=None, scalar_return_value=None): - """Return (mock_db, cm, session) ready to patch 'core.ops.ops_trace_manager.db' - and 'core.ops.ops_trace_manager.Session'. +@pytest.fixture +def orm_session(sqlite_engine: Engine) -> Iterator[Session]: + models = ( + App, + Tenant, + Provider, + ProviderCredential, + ProviderModel, + ProviderModelCredential, + BuiltinToolProvider, + ApiToolProvider, + WorkflowToolProvider, + MCPToolProvider, + ) + tables = [model.metadata.tables[model.__tablename__] for model in models] + TypeBase.metadata.create_all(sqlite_engine, tables=tables) - Provide either scalar_side_effect (list, for multiple calls) or - scalar_return_value (single value). - """ - mock_db = MagicMock() - mock_db.engine = MagicMock() + with patch.object(type(db), "engine", new_callable=PropertyMock, return_value=sqlite_engine): + with Session(sqlite_engine, expire_on_commit=False) as session: + yield session - session = MagicMock() - if scalar_side_effect is not None: - session.scalar.side_effect = scalar_side_effect + +def _persist_app(session: Session, *, tenant_id: str, name: str = "MyApp") -> App: + app = App( + id=str(uuid.uuid4()), + tenant_id=tenant_id, + name=name, + mode=AppMode.WORKFLOW, + icon_type=IconType.EMOJI, + icon="workflow", + icon_background="#FFFFFF", + enable_site=True, + enable_api=False, + ) + session.add(app) + session.commit() + return app + + +def _persist_tenant(session: Session, *, name: str = "MyWorkspace") -> Tenant: + tenant = Tenant(name=name) + session.add(tenant) + session.commit() + return tenant + + +def _persist_tool_provider( + session: Session, provider_type: str +) -> BuiltinToolProvider | ApiToolProvider | WorkflowToolProvider | MCPToolProvider: + tenant_id = str(uuid.uuid4()) + user_id = str(uuid.uuid4()) + if provider_type in {"builtin", "plugin"}: + provider = BuiltinToolProvider( + name="CredentialA", + tenant_id=tenant_id, + user_id=user_id, + provider="test/provider", + ) + elif provider_type == "api": + provider = ApiToolProvider( + name="CredentialA", + icon="icon.svg", + schema="{}", + schema_type_str=ApiProviderSchemaType.OPENAPI, + user_id=user_id, + tenant_id=tenant_id, + description="API provider", + tools_str="[]", + credentials_str="{}", + ) + elif provider_type == "workflow": + provider = WorkflowToolProvider( + name="CredentialA", + label="CredentialA", + icon="icon.svg", + app_id=str(uuid.uuid4()), + version="1", + user_id=user_id, + tenant_id=tenant_id, + description="Workflow provider", + ) + elif provider_type == "mcp": + provider = MCPToolProvider( + name="CredentialA", + server_identifier="credential-a", + server_url="https://example.com/mcp", + server_url_hash="credential-a-hash", + icon="icon.svg", + tenant_id=tenant_id, + user_id=user_id, + ) else: - session.scalar.return_value = scalar_return_value + raise ValueError(f"unsupported provider type: {provider_type}") - cm = MagicMock() - cm.__enter__ = MagicMock(return_value=session) - cm.__exit__ = MagicMock(return_value=False) + session.add(provider) + session.commit() + return provider - return mock_db, cm, session + +def _persist_provider_credential( + session: Session, + *, + tenant_id: str, + credential_name: str = "ProvCredName", +) -> ProviderCredential: + credential = ProviderCredential( + tenant_id=tenant_id, + provider_name="openai", + credential_name=credential_name, + encrypted_config="{}", + ) + session.add(credential) + session.commit() + return credential + + +def _persist_model_credential( + session: Session, + *, + tenant_id: str, + credential_name: str = "ModelCredName", +) -> ProviderModelCredential: + credential = ProviderModelCredential( + tenant_id=tenant_id, + provider_name="openai", + model_name="gpt-4", + model_type=ModelType.LLM, + credential_name=credential_name, + encrypted_config="{}", + ) + session.add(credential) + session.commit() + return credential + + +def _persist_provider( + session: Session, + *, + tenant_id: str, + credential_id: str | None, +) -> Provider: + provider = Provider( + tenant_id=tenant_id, + provider_name="openai", + provider_type=ProviderType.CUSTOM, + credential_id=credential_id, + ) + session.add(provider) + session.commit() + return provider + + +def _persist_provider_model( + session: Session, + *, + tenant_id: str, + credential_id: str | None, +) -> ProviderModel: + model = ProviderModel( + tenant_id=tenant_id, + provider_name="openai", + model_name="gpt-4", + model_type=ModelType.LLM, + credential_id=credential_id, + ) + session.add(model) + session.commit() + return model + + +@contextmanager +def _raise_on_table(engine: Engine, table_name: str) -> Iterator[None]: + """Raise only when SQL targets the named table, leaving other real lookups intact.""" + + def fail_target_query(_conn, _cursor, statement, _parameters, _context, _executemany): + if f"FROM {table_name}" in statement: + raise RuntimeError(f"forced failure for {table_name}") + + event.listen(engine, "before_cursor_execute", fail_target_query) + try: + yield + finally: + event.remove(engine, "before_cursor_execute", fail_target_query) # --------------------------------------------------------------------------- @@ -47,62 +223,42 @@ def _make_db_and_session_patches(scalar_side_effect=None, scalar_return_value=No class TestLookupAppAndWorkspaceNames: """Tests for _lookup_app_and_workspace_names(app_id, tenant_id).""" - def test_both_found(self): + def test_both_found(self, orm_session: Session): """Returns (app_name, workspace_name) when both records exist.""" from core.ops.ops_trace_manager import _lookup_app_and_workspace_names - mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", "MyWorkspace"]) - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456") + tenant = _persist_tenant(orm_session) + app = _persist_app(orm_session, tenant_id=tenant.id) + app_name, workspace_name = _lookup_app_and_workspace_names(app.id, tenant.id) assert app_name == "MyApp" assert workspace_name == "MyWorkspace" - def test_app_only_found(self): + def test_app_only_found(self, orm_session: Session): """Returns (app_name, '') when tenant record is absent.""" from core.ops.ops_trace_manager import _lookup_app_and_workspace_names - mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", None]) - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456") + app = _persist_app(orm_session, tenant_id=str(uuid.uuid4())) + app_name, workspace_name = _lookup_app_and_workspace_names(app.id, str(uuid.uuid4())) assert app_name == "MyApp" assert workspace_name == "" - def test_tenant_only_found(self): + def test_tenant_only_found(self, orm_session: Session): """Returns ('', workspace_name) when app record is absent.""" from core.ops.ops_trace_manager import _lookup_app_and_workspace_names - mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, "MyWorkspace"]) - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456") + tenant = _persist_tenant(orm_session) + app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), tenant.id) assert app_name == "" assert workspace_name == "MyWorkspace" - def test_neither_found(self): + def test_neither_found(self, orm_session: Session): """Returns ('', '') when both DB lookups return None.""" from core.ops.ops_trace_manager import _lookup_app_and_workspace_names - mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, None]) - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456") + app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), str(uuid.uuid4())) assert app_name == "" assert workspace_name == "" @@ -111,50 +267,30 @@ class TestLookupAppAndWorkspaceNames: """Returns ('', '') immediately when both IDs are None — no DB access.""" from core.ops.ops_trace_manager import _lookup_app_and_workspace_names - mock_db = MagicMock() - mock_session_cls = MagicMock() + app_name, workspace_name = _lookup_app_and_workspace_names(None, None) - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", mock_session_cls), - ): - app_name, workspace_name = _lookup_app_and_workspace_names(None, None) - - mock_session_cls.assert_not_called() assert app_name == "" assert workspace_name == "" - def test_app_id_none_only_queries_tenant(self): + def test_app_id_none_only_queries_tenant(self, orm_session: Session): """When app_id is None, only the tenant query is issued.""" from core.ops.ops_trace_manager import _lookup_app_and_workspace_names - mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyWorkspace") - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - app_name, workspace_name = _lookup_app_and_workspace_names(None, "tenant-456") + tenant = _persist_tenant(orm_session, name="OnlyWorkspace") + app_name, workspace_name = _lookup_app_and_workspace_names(None, tenant.id) assert app_name == "" assert workspace_name == "OnlyWorkspace" - assert session.scalar.call_count == 1 - def test_tenant_id_none_only_queries_app(self): + def test_tenant_id_none_only_queries_app(self, orm_session: Session): """When tenant_id is None, only the app query is issued.""" from core.ops.ops_trace_manager import _lookup_app_and_workspace_names - mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyApp") - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - app_name, workspace_name = _lookup_app_and_workspace_names("app-123", None) + app = _persist_app(orm_session, tenant_id=str(uuid.uuid4()), name="OnlyApp") + app_name, workspace_name = _lookup_app_and_workspace_names(app.id, None) assert app_name == "OnlyApp" assert workspace_name == "" - assert session.scalar.call_count == 1 # --------------------------------------------------------------------------- @@ -166,32 +302,20 @@ class TestLookupCredentialName: """Tests for _lookup_credential_name(credential_id, provider_type).""" @pytest.mark.parametrize("provider_type", ["builtin", "plugin", "api", "workflow", "mcp"]) - def test_known_provider_types_return_name(self, provider_type): + def test_known_provider_types_return_name(self, provider_type: str, orm_session: Session): """Each valid provider_type results in a DB query and returns the credential name.""" from core.ops.ops_trace_manager import _lookup_credential_name - mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="CredentialA") - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - result = _lookup_credential_name("cred-123", provider_type) + provider = _persist_tool_provider(orm_session, provider_type) + result = _lookup_credential_name(provider.id, provider_type) assert result == "CredentialA" - session.scalar.assert_called_once() - def test_credential_not_found_returns_empty_string(self): + def test_credential_not_found_returns_empty_string(self, orm_session: Session): """Returns '' when DB yields None for the given credential_id.""" from core.ops.ops_trace_manager import _lookup_credential_name - mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None) - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - result = _lookup_credential_name("cred-999", "api") + result = _lookup_credential_name(str(uuid.uuid4()), "api") assert result == "" @@ -199,48 +323,24 @@ class TestLookupCredentialName: """Returns '' immediately for an unrecognised provider_type — no DB access.""" from core.ops.ops_trace_manager import _lookup_credential_name - mock_db = MagicMock() - mock_session_cls = MagicMock() + result = _lookup_credential_name(str(uuid.uuid4()), "unknown_type") - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", mock_session_cls), - ): - result = _lookup_credential_name("cred-123", "unknown_type") - - mock_session_cls.assert_not_called() assert result == "" def test_none_credential_id_returns_empty_string_without_db(self): """Returns '' immediately when credential_id is None — no DB access.""" from core.ops.ops_trace_manager import _lookup_credential_name - mock_db = MagicMock() - mock_session_cls = MagicMock() + result = _lookup_credential_name(None, "api") - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", mock_session_cls), - ): - result = _lookup_credential_name(None, "api") - - mock_session_cls.assert_not_called() assert result == "" def test_none_provider_type_returns_empty_string_without_db(self): """Returns '' immediately when provider_type is None — no DB access.""" from core.ops.ops_trace_manager import _lookup_credential_name - mock_db = MagicMock() - mock_session_cls = MagicMock() + result = _lookup_credential_name(str(uuid.uuid4()), None) - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", mock_session_cls), - ): - result = _lookup_credential_name("cred-123", None) - - mock_session_cls.assert_not_called() assert result == "" def test_builtin_and_plugin_map_to_same_model(self): @@ -281,106 +381,78 @@ class TestLookupCredentialName: class TestLookupLlmCredentialInfo: """Tests for _lookup_llm_credential_info(tenant_id, provider, model, model_type).""" - def _provider_record(self, credential_id: str | None = None) -> MagicMock: - record = MagicMock() - record.credential_id = credential_id - return record - - def _model_record(self, credential_id: str | None = None) -> MagicMock: - record = MagicMock() - record.credential_id = credential_id - return record - - def test_model_level_credential_found(self): + def test_model_level_credential_found(self, orm_session: Session): """Returns model-level credential_id and name when ProviderModel has a credential.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - provider_record = self._provider_record(credential_id=None) - model_record = self._model_record(credential_id="model-cred-id") + tenant_id = str(uuid.uuid4()) + model_credential = _persist_model_credential(orm_session, tenant_id=tenant_id) + _persist_provider(orm_session, tenant_id=tenant_id, credential_id=None) + _persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=model_credential.id) - # scalar calls: (1) Provider, (2) ProviderModel, (3) ProviderModelCredential.credential_name - mock_db, cm, _session = _make_db_and_session_patches( - scalar_side_effect=[provider_record, model_record, "ModelCredName"] + decoy_tenant_id = str(uuid.uuid4()) + decoy_credential = _persist_model_credential( + orm_session, + tenant_id=decoy_tenant_id, + credential_name="WrongTenantCredential", ) + _persist_provider(orm_session, tenant_id=decoy_tenant_id, credential_id=None) + _persist_provider_model(orm_session, tenant_id=decoy_tenant_id, credential_id=decoy_credential.id) - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4") + cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4") - assert cred_id == "model-cred-id" + assert cred_id == model_credential.id assert cred_name == "ModelCredName" - def test_provider_level_fallback_when_no_model_credential(self): + def test_provider_level_fallback_when_no_model_credential(self, orm_session: Session): """Falls back to provider-level credential when ProviderModel has no credential_id.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - provider_record = self._provider_record(credential_id="prov-cred-id") - model_record = self._model_record(credential_id=None) + tenant_id = str(uuid.uuid4()) + provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id) + _persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id) + _persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None) - # scalar calls: (1) Provider, (2) ProviderModel (no cred), (3) ProviderCredential.credential_name - mock_db, cm, _session = _make_db_and_session_patches( - scalar_side_effect=[provider_record, model_record, "ProvCredName"] - ) + cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4") - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4") - - assert cred_id == "prov-cred-id" + assert cred_id == provider_credential.id assert cred_name == "ProvCredName" - def test_provider_level_fallback_when_no_model_record(self): + def test_provider_level_fallback_when_no_model_record(self, orm_session: Session): """Falls back to provider-level credential when no ProviderModel row exists.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - provider_record = self._provider_record(credential_id="prov-cred-id") + tenant_id = str(uuid.uuid4()) + provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id) + _persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id) - # scalar calls: (1) Provider, (2) ProviderModel → None, (3) ProviderCredential.credential_name - mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, None, "ProvCredName"]) + cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4") - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4") - - assert cred_id == "prov-cred-id" + assert cred_id == provider_credential.id assert cred_name == "ProvCredName" - def test_no_model_arg_uses_provider_level_only(self): + def test_no_model_arg_uses_provider_level_only(self, orm_session: Session): """When model is None, skips ProviderModel query and uses provider credential.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - provider_record = self._provider_record(credential_id="prov-cred-id") + tenant_id = str(uuid.uuid4()) + provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id) + _persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id) - # scalar calls: (1) Provider, (2) ProviderCredential.credential_name — no ProviderModel - mock_db, cm, session = _make_db_and_session_patches(scalar_side_effect=[provider_record, "ProvCredName"]) + cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", None) - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", None) - - assert cred_id == "prov-cred-id" + assert cred_id == provider_credential.id assert cred_name == "ProvCredName" - assert session.scalar.call_count == 2 - def test_provider_not_found_returns_none_and_empty(self): + def test_provider_not_found_returns_none_and_empty(self, orm_session: Session): """Returns (None, '') when Provider record does not exist.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None) + other_tenant_id = str(uuid.uuid4()) + _persist_provider(orm_session, tenant_id=other_tenant_id, credential_id=None) + tenant_id = str(uuid.uuid4()) - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4") + cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4") assert cred_id is None assert cred_name == "" @@ -389,16 +461,8 @@ class TestLookupLlmCredentialInfo: """Returns (None, '') immediately when tenant_id is None — no DB access.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - mock_db = MagicMock() - mock_session_cls = MagicMock() + cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4") - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", mock_session_cls), - ): - cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4") - - mock_session_cls.assert_not_called() assert cred_id is None assert cred_name == "" @@ -406,69 +470,46 @@ class TestLookupLlmCredentialInfo: """Returns (None, '') immediately when provider is None — no DB access.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - mock_db = MagicMock() - mock_session_cls = MagicMock() + cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), None, "gpt-4") - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", mock_session_cls), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", None, "gpt-4") - - mock_session_cls.assert_not_called() assert cred_id is None assert cred_name == "" - def test_db_error_on_outer_query_returns_none_and_empty(self): + def test_db_error_on_outer_query_returns_none_and_empty(self, orm_session: Session, sqlite_engine: Engine): """Returns (None, '') and logs a warning when the outer DB query raises.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - mock_db, cm, session = _make_db_and_session_patches() - session.scalar.side_effect = Exception("DB connection failed") - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4") + with _raise_on_table(sqlite_engine, "providers"): + cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), "openai", "gpt-4") assert cred_id is None assert cred_name == "" - def test_credential_name_lookup_failure_returns_id_with_empty_name(self): + def test_credential_name_lookup_failure_returns_id_with_empty_name( + self, orm_session: Session, sqlite_engine: Engine + ): """When credential name sub-query fails, returns cred_id but '' for name.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - provider_record = self._provider_record(credential_id="prov-cred-id") + tenant_id = str(uuid.uuid4()) + provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id) + _persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id) - # Provider found, no model record, then name lookup raises - mock_db, cm, _session = _make_db_and_session_patches( - scalar_side_effect=[provider_record, None, Exception("deleted")] - ) + with _raise_on_table(sqlite_engine, "provider_credentials"): + cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4") - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4") - - assert cred_id == "prov-cred-id" + assert cred_id == provider_credential.id assert cred_name == "" - def test_no_credential_on_provider_or_model_returns_none_id(self): + def test_no_credential_on_provider_or_model_returns_none_id(self, orm_session: Session): """Returns (None, '') when neither provider nor model has a credential_id.""" from core.ops.ops_trace_manager import _lookup_llm_credential_info - provider_record = self._provider_record(credential_id=None) - model_record = self._model_record(credential_id=None) + tenant_id = str(uuid.uuid4()) + _persist_provider(orm_session, tenant_id=tenant_id, credential_id=None) + _persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None) - mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, model_record]) - - with ( - patch("core.ops.ops_trace_manager.db", mock_db), - patch("core.ops.ops_trace_manager.Session", return_value=cm), - ): - cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4") + cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4") assert cred_id is None assert cred_name == "" From 29801d6a65c9b170e644b1915507c1a018a22e4d Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 19:45:11 +0900 Subject: [PATCH 059/531] test: use SQLite sessions in core tools (#39077) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- .../core/tools/workflow_as_tool/test_tool.py | 366 +++++++++++------- 1 file changed, 223 insertions(+), 143 deletions(-) diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py index 8df8e28bda4..34fe819e0d8 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py @@ -1,16 +1,16 @@ -"""Unit tests for workflow-as-tool behavior. - -StubSession/StubScalars emulate SQLAlchemy session/scalars with minimal methods -(`scalar`, `scalars`, `expunge`, `commit`, `refresh`, context manager) to keep -database access mocked and predictable in tests. -""" +"""Unit tests for workflow-as-tool behavior with real SQLite ORM boundaries.""" import json +import uuid +from collections.abc import Iterator +from dataclasses import dataclass from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from sqlalchemy import Engine, inspect +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom from core.tools.__base.tool_runtime import ToolRuntime @@ -23,74 +23,142 @@ from core.tools.entities.tool_entities import ( ToolProviderType, ) from core.tools.errors import ToolInvokeError +from core.tools.workflow_as_tool import tool as workflow_tool_module from core.tools.workflow_as_tool.tool import WorkflowTool from graphon.file import FILE_MODEL_IDENTITY, FileTransferMethod, FileType +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.base import TypeBase +from models.enums import EndUserType +from models.model import App, AppMode, EndUser +from models.workflow import Workflow, WorkflowType + +TENANT_ID = "00000000-0000-0000-0000-000000000001" +OTHER_TENANT_ID = "00000000-0000-0000-0000-000000000002" +APP_ID = "00000000-0000-0000-0000-000000000003" +ACCOUNT_ID = "00000000-0000-0000-0000-000000000004" +END_USER_ID = "00000000-0000-0000-0000-000000000005" +CREATOR_ID = "00000000-0000-0000-0000-000000000006" -class StubScalars: - """Minimal stub for SQLAlchemy scalar results.""" - - _value: Any - - def __init__(self, value: Any) -> None: - self._value = value - - def first(self) -> Any: - return self._value +@dataclass(frozen=True) +class SqliteToolDb: + engine: Engine + session_maker: sessionmaker[Session] + caller_session: Session -class StubSession: - """Minimal stub for session_factory-created sessions.""" +@pytest.fixture +def sqlite_tool_db( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, +) -> Iterator[SqliteToolDb]: + """Bind service-owned sessions and Account tenant reloads to SQLite.""" + models = (App, Workflow, EndUser, Account, Tenant, TenantAccountJoin) + TypeBase.metadata.create_all(sqlite_engine, tables=[model.__table__ for model in models]) + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr(workflow_tool_module.session_factory, "create_session", session_maker) - scalar_results: list[Any] - scalars_results: list[Any] - expunge_calls: list[object] + from models import account as account_module - def __init__(self, *, scalar_results: list[Any] | None = None, scalars_results: list[Any] | None = None) -> None: - self.scalar_results = list(scalar_results or []) - self.scalars_results = list(scalars_results or []) - self.expunge_calls: list[object] = [] - - def scalar(self, _stmt: Any) -> Any: - return self.scalar_results.pop(0) - - def scalars(self, _stmt: Any) -> StubScalars: - return StubScalars(self.scalars_results.pop(0)) - - def expunge(self, value: Any) -> None: - self.expunge_calls.append(value) - - def begin(self) -> "StubSession": - return self - - def commit(self) -> None: - pass - - def refresh(self, _value: Any) -> None: - pass - - def close(self) -> None: - pass - - def __enter__(self) -> "StubSession": - return self - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: - return False + monkeypatch.setattr(account_module, "db", SimpleNamespace(engine=sqlite_engine)) + with session_maker() as caller_session: + yield SqliteToolDb(engine=sqlite_engine, session_maker=session_maker, caller_session=caller_session) -def _build_tool() -> WorkflowTool: +def _persist_tenant(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Tenant: + tenant = Tenant(name="Tenant") + tenant.id = tenant_id + db.caller_session.add(tenant) + db.caller_session.commit() + return tenant + + +def _persist_account(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Account: + account = Account(name="Account", email="account@example.com") + account.id = ACCOUNT_ID + join = TenantAccountJoin( + tenant_id=tenant_id, + account_id=account.id, + current=True, + role=TenantAccountRole.NORMAL, + ) + db.caller_session.add_all([account, join]) + db.caller_session.commit() + return account + + +def _persist_end_user( + db: SqliteToolDb, + *, + end_user_id: str = END_USER_ID, + tenant_id: str = TENANT_ID, +) -> EndUser: + end_user = EndUser( + id=end_user_id, + tenant_id=tenant_id, + app_id=APP_ID, + type=EndUserType.SERVICE_API, + name="End user", + session_id="end-user-session", + ) + db.caller_session.add(end_user) + db.caller_session.commit() + return end_user + + +def _persist_app(db: SqliteToolDb) -> App: + app = App( + id=APP_ID, + tenant_id=TENANT_ID, + name="Workflow app", + description="", + mode=AppMode.WORKFLOW, + icon_type=None, + icon="", + icon_background=None, + app_model_config_id=None, + workflow_id=None, + enable_site=False, + enable_api=True, + max_active_requests=None, + created_by=CREATOR_ID, + ) + db.caller_session.add(app) + db.caller_session.commit() + return app + + +def _persist_workflow(db: SqliteToolDb, *, version: str, workflow_id: str | None = None) -> Workflow: + workflow = Workflow.new( + tenant_id=TENANT_ID, + app_id=APP_ID, + type=WorkflowType.WORKFLOW.value, + version=version, + graph=json.dumps({"nodes": [], "edges": []}), + features="{}", + created_by=CREATOR_ID, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + workflow.id = workflow_id or str(uuid.uuid4()) + db.caller_session.add(workflow) + db.caller_session.commit() + return workflow + + +def _build_tool(*, tenant_id: str = "test_tool", workflow_app_id: str = "app-1", version: str = "1") -> WorkflowTool: entity = ToolEntity( identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"), parameters=[], description=None, has_runtime_parameters=False, ) - runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE) + runtime = ToolRuntime(tenant_id=tenant_id, invoke_from=InvokeFrom.EXPLORE) return WorkflowTool( - workflow_app_id="app-1", + workflow_app_id=workflow_app_id, workflow_as_tool_id="wf-tool-1", - version="1", + version=version, workflow_entities={}, workflow_call_depth=1, entity=entity, @@ -98,7 +166,10 @@ def _build_tool() -> WorkflowTool: ) -def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure that WorkflowTool will throw a `ToolInvokeError` exception when `WorkflowAppGenerator.generate` returns a result with `error` key inside the `data` element. @@ -122,11 +193,14 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel with pytest.raises(ToolInvokeError) as exc_info: # WorkflowTool always returns a generator, so we need to iterate to # actually `run` the tool. - list(tool.invoke(MagicMock(), "test_user", {})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) assert exc_info.value.args == ("oops",) -def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_does_not_use_pause_state_config( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure pause_state_config is passed as None.""" tool = _build_tool() @@ -140,14 +214,17 @@ def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.Monke monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) - list(tool.invoke(MagicMock(), "test_user", {})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) call_kwargs = generate_mock.call_args.kwargs assert "pause_state_config" in call_kwargs assert call_kwargs["pause_state_config"] is None -def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_passes_parent_trace_context_from_runtime( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure nested workflow runtime metadata is forwarded as parent trace context.""" tool = _build_tool() tool.set_parent_trace_context( @@ -165,7 +242,7 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pyt monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) - list(tool.invoke(MagicMock(), "test_user", {})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) call_kwargs = generate_mock.call_args.kwargs assert call_kwargs["args"]["parent_trace_context"].model_dump() == { @@ -174,7 +251,10 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pyt } -def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_passes_parent_trace_session_id( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure nested workflows inherit the parent observability session ID.""" tool = _build_tool() tool.entity.parameters = [ @@ -197,14 +277,17 @@ def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.Monkey monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) - list(tool.invoke(MagicMock(), "test_user", {"trace_session_id": "user-input-session"})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"trace_session_id": "user-input-session"})) call_kwargs = generate_mock.call_args.kwargs assert call_kwargs["args"]["inputs"]["trace_session_id"] == "user-input-session" assert call_kwargs["args"]["trace_session_id"] == "session-1" -def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure private trace context does not overwrite same-named workflow inputs.""" tool = _build_tool() tool.entity.parameters = [ @@ -238,7 +321,7 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypat list( tool.invoke( - MagicMock(), + sqlite_tool_db.caller_session, "test_user", { "outer_workflow_run_id": "user-workflow-input", @@ -256,7 +339,10 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypat } -def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_can_clear_parent_trace_context( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure reused WorkflowTool instances do not keep stale parent trace context.""" tool = _build_tool() tool.set_parent_trace_context( @@ -275,13 +361,16 @@ def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.Monkey monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) - list(tool.invoke(MagicMock(), "test_user", {})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) call_kwargs = generate_mock.call_args.kwargs assert "parent_trace_context" not in call_kwargs["args"] -def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_can_clear_trace_session_id( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure reused WorkflowTool instances do not keep stale trace session IDs.""" tool = _build_tool() tool.set_trace_session_id("session-1") @@ -297,7 +386,7 @@ def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatc monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) - list(tool.invoke(MagicMock(), "test_user", {})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) call_kwargs = generate_mock.call_args.kwargs assert "trace_session_id" not in call_kwargs["args"] @@ -315,6 +404,7 @@ def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatc def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete( monkeypatch: pytest.MonkeyPatch, runtime_parameters: dict[str, Any], + sqlite_tool_db: SqliteToolDb, ): """Ensure incomplete runtime metadata does not leak parent trace context into generator args.""" tool = _build_tool() @@ -330,13 +420,16 @@ def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete( monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) - list(tool.invoke(MagicMock(), "test_user", {})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) call_kwargs = generate_mock.call_args.kwargs assert "parent_trace_context" not in call_kwargs["args"] -def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_should_generate_variable_messages_for_outputs( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Test that WorkflowTool should generate variable messages when there are outputs""" tool = _build_tool() @@ -359,7 +452,7 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) # Execute tool invocation - messages = list(tool.invoke(MagicMock(), "test_user", {})) + messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) # Verify variable messages variable_messages = [msg for msg in messages if msg.type == ToolInvokeMessage.MessageType.VARIABLE] @@ -382,7 +475,10 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch assert json_messages[0].message.json_object == mock_outputs -def test_workflow_tool_should_handle_empty_outputs(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_should_handle_empty_outputs( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Test that WorkflowTool should handle empty outputs correctly""" tool = _build_tool() @@ -402,7 +498,7 @@ def test_workflow_tool_should_handle_empty_outputs(monkeypatch: pytest.MonkeyPat monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None) # Execute tool invocation - messages = list(tool.invoke(MagicMock(), "test_user", {})) + messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {})) # Verify generated messages # Should contain: 0 variable messages + 1 text message + 1 JSON message = 2 messages @@ -458,41 +554,32 @@ def test_create_file_message_should_include_file_marker(): assert message.meta == {"file": file_obj} -def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch): +def test_resolve_user_from_database_falls_back_to_end_user(sqlite_tool_db: SqliteToolDb): """Ensure worker context can resolve EndUser when Account is missing.""" - - tenant = SimpleNamespace(id="tenant_id") - end_user = SimpleNamespace(id="end_user_id", tenant_id="tenant_id") - - # Monkeypatch session factory to return our stub session - stub_session = StubSession(scalar_results=[tenant, None, end_user]) - monkeypatch.setattr( - "core.tools.workflow_as_tool.tool.session_factory.create_session", - lambda: stub_session, + _persist_tenant(sqlite_tool_db) + end_user = _persist_end_user(sqlite_tool_db) + other_tenant_end_user = _persist_end_user( + sqlite_tool_db, + end_user_id="00000000-0000-0000-0000-000000000007", + tenant_id=OTHER_TENANT_ID, ) - tool = _build_tool() + tool = _build_tool(tenant_id=TENANT_ID) tool.runtime.invoke_from = InvokeFrom.SERVICE_API - tool.runtime.tenant_id = "tenant_id" resolved_user = tool._resolve_user_from_database(user_id=end_user.id) - assert resolved_user is end_user - assert stub_session.expunge_calls == [end_user] + assert isinstance(resolved_user, EndUser) + assert resolved_user.id == end_user.id + assert resolved_user.tenant_id == TENANT_ID + assert inspect(resolved_user).detached is True + assert tool._resolve_user_from_database(user_id=other_tenant_end_user.id) is None -def test_resolve_user_from_database_returns_none_when_no_tenant(monkeypatch: pytest.MonkeyPatch): +def test_resolve_user_from_database_returns_none_when_no_tenant(sqlite_tool_db: SqliteToolDb): """Return None if tenant cannot be found in worker context.""" - - # Monkeypatch session factory to return our stub session with no tenant - monkeypatch.setattr( - "core.tools.workflow_as_tool.tool.session_factory.create_session", - lambda: StubSession(scalar_results=[None]), - ) - - tool = _build_tool() + tool = _build_tool(tenant_id=OTHER_TENANT_ID) tool.runtime.invoke_from = InvokeFrom.SERVICE_API - tool.runtime.tenant_id = "missing_tenant" resolved_user = tool._resolve_user_from_database(user_id="any") @@ -544,7 +631,10 @@ def test_extract_usage_from_nested(): assert nested == {"total_tokens": 3} -def test_invoke_raises_when_user_not_found(monkeypatch: pytest.MonkeyPatch): +def test_invoke_raises_when_user_not_found( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Raise ToolInvokeError when user resolution fails.""" tool = _build_tool() monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None) @@ -552,58 +642,45 @@ def test_invoke_raises_when_user_not_found(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: None) with pytest.raises(ToolInvokeError, match="User not found"): - list(tool.invoke(MagicMock(), "missing", {})) + list(tool.invoke(sqlite_tool_db.caller_session, "missing", {})) -def test_resolve_user_from_database_returns_account(monkeypatch: pytest.MonkeyPatch): +def test_resolve_user_from_database_returns_account(sqlite_tool_db: SqliteToolDb): """Resolve Account and set tenant in worker context.""" - tenant = SimpleNamespace(id="tenant_id") - account = SimpleNamespace(id="account_id", current_tenant=None) - set_current_tenant = Mock(side_effect=lambda tenant, *, session: setattr(account, "current_tenant", tenant)) - account.set_current_tenant_with_session = set_current_tenant - session = StubSession(scalar_results=[tenant, account]) + tenant = _persist_tenant(sqlite_tool_db) + account = _persist_account(sqlite_tool_db) + tool = _build_tool(tenant_id=TENANT_ID) - monkeypatch.setattr("core.tools.workflow_as_tool.tool.session_factory.create_session", lambda: session) - tool = _build_tool() - tool.runtime.tenant_id = "tenant_id" - - resolved = tool._resolve_user_from_database(user_id="account_id") - assert resolved is account - assert account.current_tenant is tenant - set_current_tenant.assert_called_once_with(tenant, session=session) - assert session.expunge_calls == [account] + resolved = tool._resolve_user_from_database(user_id=account.id) + assert isinstance(resolved, Account) + assert resolved.id == account.id + assert resolved.current_tenant_id == tenant.id + assert inspect(resolved).detached is True -def test_get_workflow_and_get_app_db_branches(monkeypatch: pytest.MonkeyPatch): +def test_get_workflow_and_get_app_db_branches(sqlite_tool_db: SqliteToolDb): """Cover workflow/app retrieval branches and error cases.""" - tool = _build_tool() - latest_workflow = SimpleNamespace(id="wf-latest") - specific_workflow = SimpleNamespace(id="wf-v1") - app = SimpleNamespace(id="app-1") - sessions = iter( - [ - StubSession(scalar_results=[], scalars_results=[latest_workflow]), - StubSession(scalar_results=[specific_workflow], scalars_results=[]), - StubSession(scalar_results=[app], scalars_results=[]), - ] - ) - monkeypatch.setattr( - "core.tools.workflow_as_tool.tool.session_factory.create_session", - lambda: next(sessions), - ) + app = _persist_app(sqlite_tool_db) + specific_workflow = _persist_workflow(sqlite_tool_db, version="1") + latest_workflow = _persist_workflow(sqlite_tool_db, version="2") + _persist_workflow(sqlite_tool_db, version=Workflow.VERSION_DRAFT) + tool = _build_tool(tenant_id=TENANT_ID, workflow_app_id=APP_ID) - assert tool._get_workflow("app-1", "") is latest_workflow - assert tool._get_workflow("app-1", "1") is specific_workflow - assert tool._get_app("app-1") is app + latest = tool._get_workflow(APP_ID, "") + specific = tool._get_workflow(APP_ID, "1") + resolved_app = tool._get_app(APP_ID) + + assert latest.id == latest_workflow.id + assert specific.id == specific_workflow.id + assert resolved_app.id == app.id + assert inspect(latest).detached is True + assert inspect(specific).detached is True + assert inspect(resolved_app).detached is True - monkeypatch.setattr( - "core.tools.workflow_as_tool.tool.session_factory.create_session", - lambda: StubSession(scalar_results=[None, None], scalars_results=[None]), - ) with pytest.raises(ValueError, match="workflow not found"): - tool._get_workflow("app-1", "1") + tool._get_workflow(APP_ID, "missing") with pytest.raises(ValueError, match="app not found"): - tool._get_app("app-1") + tool._get_app("00000000-0000-0000-0000-000000000099") def _setup_transform_args_tool(monkeypatch: pytest.MonkeyPatch) -> WorkflowTool: @@ -722,7 +799,10 @@ def test_transform_args_normalizes_optional_files_parameter( assert files == [] -def test_workflow_tool_invocation_normalizes_optional_files_parameter(monkeypatch: pytest.MonkeyPatch): +def test_workflow_tool_invocation_normalizes_optional_files_parameter( + monkeypatch: pytest.MonkeyPatch, + sqlite_tool_db: SqliteToolDb, +): """Ensure casted empty FILES values do not reach workflow input validation as [None].""" tool = _build_tool() images_param = ToolParameter.get_simple_instance( @@ -741,7 +821,7 @@ def test_workflow_tool_invocation_normalizes_optional_files_parameter(monkeypatc generate_mock = MagicMock(return_value={"data": {}}) monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock) - list(tool.invoke(MagicMock(), "test_user", {"images": None})) + list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"images": None})) call_kwargs = generate_mock.call_args.kwargs assert call_kwargs["args"]["inputs"]["images"] == [] From f73f83c5d65d8111386a9bf4ced2e27702869088 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Jul 2026 19:45:16 +0900 Subject: [PATCH 060/531] test: use SQLite sessions in core app (#39075) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- ...test_message_cycle_manager_optimization.py | 466 +++++++++--------- 1 file changed, 235 insertions(+), 231 deletions(-) diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py index c0cc2d75386..e22798f47c7 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py @@ -1,24 +1,95 @@ """Unit tests for the message cycle manager optimization.""" import logging +from collections.abc import Iterator +from dataclasses import dataclass from types import SimpleNamespace from unittest.mock import Mock, patch import pytest from flask import Flask, current_app +from sqlalchemy import Engine, event, select +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.queue_entities import QueueAnnotationReplyEvent, QueueRetrieverResourcesEvent from core.app.entities.task_entities import MessageStreamResponse, StreamEvent, TaskStateMetadata +from core.app.task_pipeline import message_cycle_manager as message_cycle_manager_module from core.app.task_pipeline.message_cycle_manager import MessageCycleManager from core.rag.entities import RetrievalSourceMetadata -from models.model import App, AppMode +from graphon.file import FileTransferMethod, FileType +from models import model as model_module +from models.base import TypeBase +from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo +from models.model import App, AppMode, Conversation, MessageFile -def _patch_create_session(mock_session): - session_cm = Mock() - session_cm.__enter__ = Mock(return_value=mock_session) - session_cm.__exit__ = Mock(return_value=False) - return patch("core.app.task_pipeline.message_cycle_manager.session_factory.create_session", return_value=session_cm) +@dataclass(frozen=True) +class _SQLiteDb: + engine: Engine + session: Session + + +@pytest.fixture +def cycle_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: + """Bind request-owned and cycle-manager-owned sessions to isolated SQLite.""" + TypeBase.metadata.create_all( + sqlite_engine, + tables=[App.__table__, Conversation.__table__, MessageFile.__table__], + ) + owned_session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + with owned_session_factory() as request_session: + sqlite_db = _SQLiteDb(engine=sqlite_engine, session=request_session) + monkeypatch.setattr(message_cycle_manager_module, "db", sqlite_db) + monkeypatch.setattr(model_module, "db", sqlite_db) + monkeypatch.setattr(message_cycle_manager_module.session_factory, "create_session", owned_session_factory) + yield request_session + + +def _app(*, app_id: str = "app-id", tenant_id: str = "tenant-1") -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Test App", + description="", + mode=AppMode.CHAT, + enable_site=True, + enable_api=True, + max_active_requests=0, + ) + + +def _conversation(*, conversation_id: str = "conv-1", app_id: str = "app-id") -> Conversation: + conversation = Conversation( + app_id=app_id, + mode=AppMode.CHAT, + name="", + status="normal", + from_source=ConversationFromSource.API, + inputs={}, + ) + conversation.id = conversation_id + return conversation + + +def _message_file( + *, + file_id: str = "file-1", + message_id: str = "test-message-id", + belongs_to: MessageFileBelongsTo | None = MessageFileBelongsTo.ASSISTANT, + url: str | None = "http://example.com/image.png", + file_type: FileType = FileType.IMAGE, +) -> MessageFile: + message_file = MessageFile( + message_id=message_id, + type=file_type, + transfer_method=FileTransferMethod.TOOL_FILE, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-id", + belongs_to=belongs_to, + url=url, + ) + message_file.id = file_id + return message_file class TestMessageCycleManagerOptimization: @@ -37,30 +108,22 @@ class TestMessageCycleManagerOptimization: task_state = Mock() return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state) - def test_get_message_event_type_with_assistant_file(self, message_cycle_manager): + def test_get_message_event_type_with_assistant_file(self, message_cycle_manager, cycle_db: Session): """Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files. This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event, allowing the frontend to properly display generated image files with url field. """ - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: - # Setup mock session and message file - mock_session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session + cycle_db.add(_message_file()) + cycle_db.commit() - mock_message_file = Mock() - mock_message_file.belongs_to = "assistant" - mock_session.scalar.return_value = mock_message_file + with current_app.app_context(): + result = message_cycle_manager.get_message_event_type("test-message-id") - # Execute - with current_app.app_context(): - result = message_cycle_manager.get_message_event_type("test-message-id") + assert result == StreamEvent.MESSAGE_FILE + assert "test-message-id" in message_cycle_manager._message_has_file - # Assert - assert result == StreamEvent.MESSAGE_FILE - mock_session.scalar.assert_called_once() - - def test_get_message_event_type_with_user_file(self, message_cycle_manager): + def test_get_message_event_type_with_user_file(self, message_cycle_manager, cycle_db: Session): """Test get_message_event_type returns MESSAGE when message only has user-uploaded files. This is a regression test for the issue where user-uploaded images (belongs_to='user') @@ -68,90 +131,81 @@ class TestMessageCycleManagerOptimization: resulting in broken images in the chat UI. The query filters for belongs_to='assistant', so when only user files exist, the database query returns None, resulting in MESSAGE event type. """ - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: - # Setup mock session and message file - mock_session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session + cycle_db.add(_message_file(belongs_to=MessageFileBelongsTo.USER)) + cycle_db.commit() - # When querying for assistant files with only user files present, return None - # (simulates database query with belongs_to='assistant' filter returning no results) - mock_session.scalar.return_value = None + with current_app.app_context(): + result = message_cycle_manager.get_message_event_type("test-message-id") - # Execute - with current_app.app_context(): - result = message_cycle_manager.get_message_event_type("test-message-id") + assert result == StreamEvent.MESSAGE + assert "test-message-id" not in message_cycle_manager._message_has_file - # Assert - assert result == StreamEvent.MESSAGE - mock_session.scalar.assert_called_once() - - def test_get_message_event_type_without_message_file(self, message_cycle_manager): + def test_get_message_event_type_without_message_file(self, message_cycle_manager, cycle_db: Session): """Test get_message_event_type returns MESSAGE when message has no files.""" - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: - # Setup mock session and no message file - mock_session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - # Current implementation uses session.scalar(select(...)) - mock_session.scalar.return_value = None + assert list(cycle_db.scalars(select(MessageFile)).all()) == [] - # Execute - with current_app.app_context(): - result = message_cycle_manager.get_message_event_type("test-message-id") + with current_app.app_context(): + result = message_cycle_manager.get_message_event_type("test-message-id") - # Assert - assert result == StreamEvent.MESSAGE - mock_session.scalar.assert_called_once() + assert result == StreamEvent.MESSAGE - def test_get_message_event_type_uses_cache_without_query(self, message_cycle_manager): + def test_get_message_event_type_uses_cache_without_query( + self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine + ): """Return MESSAGE_FILE directly from in-memory cache without opening a DB session.""" message_cycle_manager._message_has_file.add("cached-message") + statements: list[str] = [] - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: + def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: result = message_cycle_manager.get_message_event_type("cached-message") + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) assert result == StreamEvent.MESSAGE_FILE - mock_session_factory.create_session.assert_not_called() + assert statements == [] - def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager): + def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager, cycle_db: Session): """MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it.""" - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: - # Setup mock session and message file - mock_session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session + cycle_db.add(_message_file()) + cycle_db.commit() - mock_message_file = Mock() - mock_message_file.belongs_to = "assistant" - mock_session.scalar.return_value = mock_message_file + with current_app.app_context(): + event_type = message_cycle_manager.get_message_event_type("test-message-id") + result = message_cycle_manager.message_to_stream_response( + answer="Hello world", message_id="test-message-id", event_type=event_type + ) - # Execute: compute event type once, then pass to message_to_stream_response - with current_app.app_context(): - event_type = message_cycle_manager.get_message_event_type("test-message-id") - result = message_cycle_manager.message_to_stream_response( - answer="Hello world", message_id="test-message-id", event_type=event_type - ) + assert isinstance(result, MessageStreamResponse) + assert result.answer == "Hello world" + assert result.id == "test-message-id" + assert result.event == StreamEvent.MESSAGE_FILE - # Assert - assert isinstance(result, MessageStreamResponse) - assert result.answer == "Hello world" - assert result.id == "test-message-id" - assert result.event == StreamEvent.MESSAGE_FILE - mock_session.scalar.assert_called_once() - - def test_message_to_stream_response_with_event_type_skips_query(self, message_cycle_manager): + def test_message_to_stream_response_with_event_type_skips_query( + self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine + ): """Test that message_to_stream_response skips database query when event_type is provided.""" - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: - # Execute with event_type provided + statements: list[str] = [] + + def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: result = message_cycle_manager.message_to_stream_response( answer="Hello world", message_id="test-message-id", event_type=StreamEvent.MESSAGE ) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) - # Assert - assert isinstance(result, MessageStreamResponse) - assert result.answer == "Hello world" - assert result.id == "test-message-id" - assert result.event == StreamEvent.MESSAGE - # Should not open a session when event_type is provided - mock_session_factory.create_session.assert_not_called() + assert isinstance(result, MessageStreamResponse) + assert result.answer == "Hello world" + assert result.id == "test-message-id" + assert result.event == StreamEvent.MESSAGE + assert statements == [] def test_message_to_stream_response_with_from_variable_selector(self, message_cycle_manager): """Test message_to_stream_response with from_variable_selector parameter.""" @@ -168,40 +222,32 @@ class TestMessageCycleManagerOptimization: assert result.from_variable_selector == ["var1", "var2"] assert result.event == StreamEvent.MESSAGE - def test_optimization_usage_example(self, message_cycle_manager): + def test_optimization_usage_example(self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine): """Test the optimization pattern that should be used by callers.""" - # Step 1: Get event type once (this queries database) - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: - mock_session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = mock_session - # Current implementation uses session.scalar(select(...)) - mock_session.scalar.return_value = None # No files + statements: list[str] = [] + + def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: with current_app.app_context(): event_type = message_cycle_manager.get_message_event_type("test-message-id") - - # Should open session once - mock_session_factory.create_session.assert_called_once() - assert event_type == StreamEvent.MESSAGE - - # Step 2: Use event_type for multiple calls (no additional queries) - with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory: - mock_session_factory.create_session.return_value.__enter__.return_value = Mock() - chunk1_response = message_cycle_manager.message_to_stream_response( answer="Chunk 1", message_id="test-message-id", event_type=event_type ) - chunk2_response = message_cycle_manager.message_to_stream_response( answer="Chunk 2", message_id="test-message-id", event_type=event_type ) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) - # Should not open session again when event_type provided - mock_session_factory.create_session.assert_not_called() - - assert chunk1_response.event == StreamEvent.MESSAGE - assert chunk2_response.event == StreamEvent.MESSAGE - assert chunk1_response.answer == "Chunk 1" - assert chunk2_response.answer == "Chunk 2" + assert event_type == StreamEvent.MESSAGE + assert len([statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]) == 1 + assert chunk1_response.event == StreamEvent.MESSAGE + assert chunk2_response.event == StreamEvent.MESSAGE + assert chunk1_response.answer == "Chunk 1" + assert chunk2_response.answer == "Chunk 2" def test_generate_conversation_name_returns_none_for_completion(self, message_cycle_manager): """Return None when completion entities are used for conversation naming. @@ -269,51 +315,38 @@ class TestMessageCycleManagerOptimization: assert message_cycle_manager._application_generate_entity.is_new_conversation is False mock_timer.assert_not_called() - def test_generate_conversation_name_worker_returns_when_conversation_missing(self, message_cycle_manager): + def test_generate_conversation_name_worker_returns_when_conversation_missing( + self, message_cycle_manager, cycle_db: Session + ): """Return early when the conversation cannot be found.""" flask_app = Flask(__name__) - db_session = Mock() - db_session.scalar.return_value = None + assert list(cycle_db.scalars(select(Conversation)).all()) == [] - with _patch_create_session(db_session): - message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello") + message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello") - db_session.commit.assert_not_called() + assert list(cycle_db.scalars(select(Conversation)).all()) == [] - def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager): + def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager, cycle_db: Session): """Return early when non-completion conversation has no app relation.""" flask_app = Flask(__name__) - conversation = SimpleNamespace(mode=AppMode.CHAT, app=None, app_id="app-id") - db_session = Mock() - db_session.scalar.return_value = conversation - db_session.get.return_value = None + conversation = _conversation() + cycle_db.add(conversation) + cycle_db.commit() - with _patch_create_session(db_session): - message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello") + message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello") - db_session.commit.assert_not_called() + assert cycle_db.get(Conversation, "conv-1").name == "" + assert cycle_db.get(App, "app-id") is None - def test_generate_conversation_name_worker_uses_cached_name(self, message_cycle_manager): + def test_generate_conversation_name_worker_uses_cached_name( + self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine + ): """Use cached conversation name when present and avoid LLM call.""" flask_app = Flask(__name__) - - class ConversationWithPoisonedApp: - mode = AppMode.CHAT - app_id = "app-id" - name = "" - - @property - def app(self): - raise AssertionError("conversation.app must not open an implicit session") - - conversation = ConversationWithPoisonedApp() - app_model = SimpleNamespace(tenant_id="tenant-1") - db_session = Mock() - db_session.scalar.return_value = conversation - db_session.get.return_value = app_model + cycle_db.add_all([_app(), _conversation()]) + cycle_db.commit() with ( - _patch_create_session(db_session) as create_session, patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, ): @@ -321,27 +354,23 @@ class TestMessageCycleManagerOptimization: message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello") + assert cycle_db.in_transaction() is False + with Session(sqlite_engine) as verification_session: + conversation = verification_session.get(Conversation, "conv-1") + assert conversation is not None assert conversation.name == "cached-title" - create_session.assert_called_once_with() - db_session.get.assert_called_once_with(App, "app-id") - db_session.commit.assert_called_once() mock_llm_generator.generate_conversation_name.assert_not_called() mock_redis.setex.assert_not_called() - def test_generate_conversation_name_worker_generates_and_caches_name(self, message_cycle_manager): + def test_generate_conversation_name_worker_generates_and_caches_name( + self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine + ): """Generate conversation name and write it to redis cache on cache miss.""" flask_app = Flask(__name__) - conversation = SimpleNamespace( - mode=AppMode.CHAT, - app=SimpleNamespace(tenant_id="tenant-1"), - app_id="app-id", - name="", - ) - db_session = Mock() - db_session.scalar.return_value = conversation + cycle_db.add_all([_app(), _conversation()]) + cycle_db.commit() with ( - _patch_create_session(db_session), patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, ): @@ -350,27 +379,27 @@ class TestMessageCycleManagerOptimization: message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello") + assert cycle_db.in_transaction() is False + with Session(sqlite_engine) as verification_session: + conversation = verification_session.get(Conversation, "conv-1") + assert conversation is not None assert conversation.name == "generated-title" - db_session.commit.assert_called_once() mock_redis.setex.assert_called_once() def test_generate_conversation_name_worker_falls_back_when_generation_fails( - self, message_cycle_manager, caplog: pytest.LogCaptureFixture + self, + message_cycle_manager, + cycle_db: Session, + sqlite_engine: Engine, + caplog: pytest.LogCaptureFixture, ): """Fallback to truncated query when LLM generation fails.""" flask_app = Flask(__name__) - conversation = SimpleNamespace( - mode=AppMode.CHAT, - app=SimpleNamespace(tenant_id="tenant-1"), - app_id="app-id", - name="", - ) - db_session = Mock() - db_session.scalar.return_value = conversation + cycle_db.add_all([_app(), _conversation()]) + cycle_db.commit() long_query = "q" * 60 with ( - _patch_create_session(db_session), patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config, @@ -382,8 +411,11 @@ class TestMessageCycleManagerOptimization: with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"): message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) + assert cycle_db.in_transaction() is False + with Session(sqlite_engine) as verification_session: + conversation = verification_session.get(Conversation, "conv-1") + assert conversation is not None assert conversation.name == (long_query[:47] + "...") - db_session.commit.assert_called_once() assert any(record.levelno == logging.ERROR for record in caplog.records) def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager): @@ -454,33 +486,25 @@ class TestMessageCycleManagerOptimization: assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1 assert message_cycle_manager._task_state.metadata.retriever_resources[1].position == 2 - def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager): + def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager, cycle_db: Session): """Build a stream response with a signed tool file URL. - Args: message_cycle_manager with mocked Session/db and sign_tool_file. + Args: message_cycle_manager with a persisted MessageFile and mocked sign_tool_file. Returns: MessageStreamResponse with signed url and belongs_to normalized to user. Side effects: Calls sign_tool_file for tool file ids. """ message_cycle_manager._application_generate_entity.task_id = "task-1" - - message_file = SimpleNamespace( - id="file-1", - type="image", - belongs_to=None, - url="tool://file.verylongextension", - message_id="msg-1", + cycle_db.add( + _message_file( + file_id="file-1", + message_id="msg-1", + belongs_to=None, + url="tool://file.verylongextension", + ) ) + cycle_db.commit() - session = Mock() - session.scalar.return_value = message_file - - with ( - patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls, - patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign, - patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db, - ): - mock_db.engine = Mock() - mock_session_cls.return_value.__enter__.return_value = session + with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign: mock_sign.return_value = "signed-url" response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-1")) @@ -514,56 +538,42 @@ class TestMessageCycleManagerOptimization: assert len(message_cycle_manager._task_state.metadata.retriever_resources) == 1 assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1 - def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager): + def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager, cycle_db: Session): """Use original URL when message file URL is already HTTP.""" message_cycle_manager._application_generate_entity.task_id = "task-http" - message_file = SimpleNamespace( - id="file-http", - type="image", - belongs_to="assistant", - url="http://example.com/pic.png", - message_id="msg-http", - ) - - session = Mock() - session.scalar.return_value = message_file - - with ( - patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls, - patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db, - ): - mock_db.engine = Mock() - mock_session_cls.return_value.__enter__.return_value = session - - response = message_cycle_manager.message_file_to_stream_response( - SimpleNamespace(message_file_id="file-http") + cycle_db.add( + _message_file( + file_id="file-http", + message_id="msg-http", + belongs_to=MessageFileBelongsTo.ASSISTANT, + url="http://example.com/pic.png", ) + ) + cycle_db.commit() + + response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-http")) assert response is not None assert response.url == "http://example.com/pic.png" assert "msg-http" in message_cycle_manager._message_has_file - def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot(self, message_cycle_manager): + def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot( + self, message_cycle_manager, cycle_db: Session + ): """Default tool file extension to .bin when URL has no extension part.""" message_cycle_manager._application_generate_entity.task_id = "task-bin" - message_file = SimpleNamespace( - id="file-bin", - type="file", - belongs_to="assistant", - url="tool-file-id", - message_id="msg-bin", + cycle_db.add( + _message_file( + file_id="file-bin", + message_id="msg-bin", + belongs_to=MessageFileBelongsTo.ASSISTANT, + url="tool-file-id", + file_type=FileType.CUSTOM, + ) ) + cycle_db.commit() - session = Mock() - session.scalar.return_value = message_file - - with ( - patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls, - patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign, - patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db, - ): - mock_db.engine = Mock() - mock_session_cls.return_value.__enter__.return_value = session + with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign: mock_sign.return_value = "signed-bin-url" response = message_cycle_manager.message_file_to_stream_response( @@ -574,19 +584,13 @@ class TestMessageCycleManagerOptimization: assert response.url == "signed-bin-url" mock_sign.assert_called_once_with(tool_file_id="tool-file-id", extension=".bin") - def test_message_file_to_stream_response_returns_none_when_file_missing(self, message_cycle_manager): + def test_message_file_to_stream_response_returns_none_when_file_missing( + self, message_cycle_manager, cycle_db: Session + ): """Return None when message file lookup does not find a record.""" - session = Mock() - session.scalar.return_value = None + assert list(cycle_db.scalars(select(MessageFile)).all()) == [] - with ( - patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls, - patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db, - ): - mock_db.engine = Mock() - mock_session_cls.return_value.__enter__.return_value = session - - response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing")) + response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing")) assert response is None From f68cabe2267295fbfb10341ea53f31b65f0a97c5 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:00:16 +0800 Subject: [PATCH 061/531] fix(web): align tour trigger DOM order (#39654) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../components/main-nav/__tests__/index.spec.tsx | 15 +++++++++++++++ web/app/components/main-nav/index.tsx | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 4a30a3edbd0..372dafc0beb 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -655,6 +655,21 @@ describe('MainNav', () => { expect(helpButton.parentElement).toHaveClass('shrink-0', 'rounded-full', 'p-1') }) + it('orders the Step-by-step Tour before the account and help actions', async () => { + localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed') + + renderMainNav() + + const tourTrigger = await screen.findByRole('button', { name: 'Open step-by-step tour' }) + const accountButton = screen.getByRole('button', { name: 'common.account.account' }) + const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }) + + expect(tourTrigger.compareDocumentPosition(accountButton)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) + expect(accountButton.compareDocumentPosition(helpButton)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) + }) + it('keeps the global navigation account section expanded on home routes', () => { localStorage.setItem(DETAIL_SIDEBAR_STORAGE_KEY, 'collapse') mockPathname = '/' diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index ee077f1d8ac..90932c841b6 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -133,6 +133,7 @@ export function MainNav({ className }: MainNavProps) { )}
+
@@ -141,7 +142,6 @@ export function MainNav({ className }: MainNavProps) {
-
) From b25b28cc76390b7958dd18fbf5b9dcd50afb9eb5 Mon Sep 17 00:00:00 2001 From: Jingyi Date: Mon, 27 Jul 2026 16:43:03 -0700 Subject: [PATCH 062/531] fix(workflow): align block icon vector sizes (#39657) --- web/app/components/workflow/block-icon.tsx | 27 ++++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 2a890ff47fc..eb59485c328 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -31,17 +31,24 @@ import { import { API_PREFIX } from '@/config' import { BlockEnum } from './types' +type BlockIconSize = 'xs' | 'sm' | 'md' + type BlockIconProps = { type: BlockEnum - size?: string + size?: BlockIconSize className?: string toolIcon?: string | { content: string; background: string } } -const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record = { +const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record = { xs: 'w-4 h-4 rounded-[5px] shadow-xs', sm: 'w-5 h-5 rounded-md shadow-xs', md: 'w-6 h-6 rounded-lg shadow-md', } +const ICON_CLASSNAME_SIZE_MAP: Record = { + xs: 'size-3', + sm: 'size-3.5', + md: 'size-4', +} const DEFAULT_ICON_MAP: Record> = { [BlockEnum.Start]: Home, @@ -144,7 +151,7 @@ const BlockIcon: FC = ({ type, size = 'sm', className, toolIcon >
) @@ -163,7 +170,7 @@ const BlockIcon: FC = ({ type, size = 'sm', className, toolIcon aria-hidden className={cn( 'i-custom-vender-workflow-start-placeholder text-text-primary opacity-30', - size === 'xs' ? 'size-3' : 'size-3.5', + ICON_CLASSNAME_SIZE_MAP[size], )} />
@@ -180,17 +187,7 @@ const BlockIcon: FC = ({ type, size = 'sm', className, toolIcon className, )} > - {showDefaultIcon && - getIcon( - type, - type === BlockEnum.TriggerSchedule || type === BlockEnum.TriggerWebhook - ? size === 'xs' - ? 'w-4 h-4' - : 'w-4.5 h-4.5' - : size === 'xs' - ? 'w-3 h-3' - : 'w-3.5 h-3.5', - )} + {showDefaultIcon && getIcon(type, ICON_CLASSNAME_SIZE_MAP[size])} {!showDefaultIcon && ( <> {typeof resolvedToolIcon === 'string' ? ( From d8506efed684142926bdcc1731016421c49d20a4 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:50:05 -0700 Subject: [PATCH 063/531] fix(cli): decouple release script tests from the live compat window (#39658) --- cli/scripts/release-naming.mjs | 10 ++++- cli/scripts/release-naming.test.ts | 52 ++++++++++++++++++-------- cli/scripts/release-r2-edge.test.ts | 10 ++++- cli/test/fixtures/pkg-manifest.ts | 57 +++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 20 deletions(-) create mode 100644 cli/test/fixtures/pkg-manifest.ts diff --git a/cli/scripts/release-naming.mjs b/cli/scripts/release-naming.mjs index 2af172f1333..d9fcc75c441 100644 --- a/cli/scripts/release-naming.mjs +++ b/cli/scripts/release-naming.mjs @@ -114,9 +114,15 @@ function die(msg) { process.exit(1) } +// Tests point this at a fixture manifest so their assertions stay fixed while +// the real version and compat window move with every release. The name is +// mirrored in test/fixtures/pkg-manifest.ts rather than imported from here, +// because this file's shebang breaks the Windows test runner. +const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH' + function loadPkg() { - const pkgUrl = new URL('../package.json', import.meta.url) - const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8')) + const pkgPath = process.env[PKG_PATH_ENV] || new URL('../package.json', import.meta.url) + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release') return { version: pkg.version, diff --git a/cli/scripts/release-naming.test.ts b/cli/scripts/release-naming.test.ts index 97552ed0823..e6f36391801 100644 --- a/cli/scripts/release-naming.test.ts +++ b/cli/scripts/release-naming.test.ts @@ -1,12 +1,19 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest' const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url)) -function run(args: string[]): { code: number; stdout: string; stderr: string } { +function run( + args: string[], + env: Record = {}, +): { code: number; stdout: string; stderr: string } { try { - const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }) + const stdout = execFileSync('node', [SCRIPT, ...args], { + encoding: 'utf8', + env: { ...process.env, ...env }, + }) return { code: 0, stdout, stderr: '' } } catch (e) { const err = e as { status?: number; stdout?: string; stderr?: string } @@ -14,45 +21,50 @@ function run(args: string[]): { code: number; stdout: string; stderr: string } { } } -describe('release-naming compat-check (compat 1.16.0..1.16.0)', () => { +describe('release-naming compat-check', () => { + const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0 + const pkgEnv = pkgManifestEnv() + const compatCheck = (difyVersion?: string) => + run(difyVersion === undefined ? ['compat-check'] : ['compat-check', difyVersion], pkgEnv).code + it('accepts a version inside the window', () => { - expect(run(['compat-check', '1.16.0']).code).toBe(0) + expect(compatCheck('2.3.0')).toBe(0) }) it('accepts the inclusive lower bound', () => { - expect(run(['compat-check', '1.16.0']).code).toBe(0) + expect(compatCheck(minDify)).toBe(0) }) it('accepts the inclusive upper bound', () => { - expect(run(['compat-check', '1.16.0']).code).toBe(0) + expect(compatCheck(maxDify)).toBe(0) }) it('accepts a v-prefixed tag', () => { - expect(run(['compat-check', 'v1.16.0']).code).toBe(0) + expect(compatCheck('v2.3.0')).toBe(0) }) it('rejects a version below the lower bound', () => { - expect(run(['compat-check', '1.15.9']).code).not.toBe(0) + expect(compatCheck('1.9.9')).not.toBe(0) }) it('rejects a version above the upper bound', () => { - expect(run(['compat-check', '1.16.1']).code).not.toBe(0) + expect(compatCheck('2.5.1')).not.toBe(0) }) - it('treats a prerelease of the bound as below it (1.16.0-rc1 < 1.16.0)', () => { - expect(run(['compat-check', '1.16.0-rc1']).code).not.toBe(0) + it('treats a prerelease of the lower bound as below it', () => { + expect(compatCheck(`${minDify}-rc1`)).not.toBe(0) }) - it('ignores build metadata on the bound (1.16.0+build == 1.16.0)', () => { - expect(run(['compat-check', '1.16.0+build123']).code).toBe(0) + it('ignores build metadata on the bound', () => { + expect(compatCheck(`${maxDify}+build123`)).toBe(0) }) - it('ignores build metadata when out of range (1.16.1+build still rejected)', () => { - expect(run(['compat-check', '1.16.1+build123']).code).not.toBe(0) + it('ignores build metadata when out of range', () => { + expect(compatCheck('2.5.1+build123')).not.toBe(0) }) it('requires a version argument', () => { - expect(run(['compat-check']).code).not.toBe(0) + expect(compatCheck()).not.toBe(0) }) }) @@ -67,6 +79,14 @@ describe('release-naming github-env', () => { for (const key of ['version', 'channel', 'prerelease', 'minDify', 'maxDify', 'tagPrefix']) expect(stdout).toMatch(new RegExp(`^${key}=`, 'm')) }) + + // The only assertion against the live manifest: the window must exist and be + // well-formed, whatever release it currently points at. + it('emits a well-formed compat window from the real cli/package.json', () => { + const { stdout } = run(['github-env']) + expect(stdout).toMatch(/^minDify=\d+\.\d+\.\d+$/m) + expect(stdout).toMatch(/^maxDify=\d+\.\d+\.\d+$/m) + }) }) describe('release-naming edge channel', () => { diff --git a/cli/scripts/release-r2-edge.test.ts b/cli/scripts/release-r2-edge.test.ts index 7c93133fddf..3054a8793b6 100644 --- a/cli/scripts/release-r2-edge.test.ts +++ b/cli/scripts/release-r2-edge.test.ts @@ -4,14 +4,20 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest' const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url)) +const PKG_ENV = pkgManifestEnv() + function run(args: string[]): { code: number; stdout: string; stderr: string } { try { return { code: 0, - stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }), + stdout: execFileSync('node', [SCRIPT, ...args], { + encoding: 'utf8', + env: { ...process.env, ...PKG_ENV }, + }), stderr: '', } } catch (e) { @@ -108,7 +114,7 @@ describe('release-r2-edge manifest', () => { it('carries the compat window from package.json', () => { const { json } = buildManifest() - expect(json.compat).toEqual({ minDify: '1.16.0', maxDify: '1.16.0' }) + expect(json.compat).toEqual(FIXTURE_COMPAT) }) it('lists all 5 targets with asset name + sha256 from the checksums file', () => { diff --git a/cli/test/fixtures/pkg-manifest.ts b/cli/test/fixtures/pkg-manifest.ts new file mode 100644 index 00000000000..92f2299ad35 --- /dev/null +++ b/cli/test/fixtures/pkg-manifest.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Mirrors PKG_PATH_ENV in scripts/release-naming.mjs, which cannot be imported +// here: its shebang breaks the Windows test runner. Divergence is self- +// reporting, not silent — the script would fall back to the real +// cli/package.json and every fixture-window assertion would fail. +const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH' + +// release-naming.mjs and release-r2-edge.mjs read their data from +// cli/package.json. Tests spawn them against this fixture instead, so +// assertions can name exact versions without tracking the live release. + +// Deliberately far from any real Dify version, and min != max so "inside the +// window" is a case distinct from either bound. +export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' } + +export const FIXTURE_TARGET_IDS = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'windows-x64', +] as const + +const FIXTURE_RELEASE = { + tagPrefix: 'difyctl-v', + binName: 'difyctl', + checksumsSuffix: '-checksums.txt', + targets: FIXTURE_TARGET_IDS.map((id) => ({ + id, + bunTarget: `bun-${id}`, + exe: id.startsWith('windows'), + })), +} + +export type PkgManifestOverrides = { + version?: string + channel?: string + compat?: { minDify: string; maxDify: string } +} + +// Returns the env additions that point a spawned script at the fixture. +export function pkgManifestEnv(overrides: PkgManifestOverrides = {}): Record { + const manifest = { + version: overrides.version ?? '0.2.0-alpha', + difyctl: { + channel: overrides.channel ?? 'alpha', + compat: overrides.compat ?? FIXTURE_COMPAT, + release: FIXTURE_RELEASE, + }, + } + const path = join(mkdtempSync(join(tmpdir(), 'difyctl-pkg-')), 'package.json') + writeFileSync(path, JSON.stringify(manifest)) + return { [PKG_PATH_ENV]: path } +} From a57b0b9b580c2a8249d4bf44c9a8efc7688f12b4 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:53:59 -0700 Subject: [PATCH 064/531] feat(plugin): allow disabling the tenant plugin model providers cache (#39632) --- api/.env.example | 1 + api/configs/feature/__init__.py | 6 ++ api/controllers/inner_api/__init__.py | 2 - .../workspace/plugin_model_providers.py | 39 ----------- api/core/plugin/plugin_service.py | 15 +++-- .../workspace/test_plugin_model_providers.py | 64 ------------------- .../services/plugin/test_plugin_service.py | 20 ++++++ docker/envs/core-services/shared.env.example | 1 + 8 files changed, 39 insertions(+), 109 deletions(-) delete mode 100644 api/controllers/inner_api/workspace/plugin_model_providers.py delete mode 100644 api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py diff --git a/api/.env.example b/api/.env.example index d9fed2d9318..683042a70ac 100644 --- a/api/.env.example +++ b/api/.env.example @@ -666,6 +666,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003 PLUGIN_REMOTE_INSTALL_HOST=localhost PLUGIN_MAX_PACKAGE_SIZE=15728640 PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 +PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400 # Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users. # Example: langgenius/openai,langgenius/gemini diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 70c629b8070..223d22082db 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -266,6 +266,12 @@ class PluginConfig(BaseSettings): default=60 * 60, ) + PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: bool = Field( + description="Whether tenant plugin model providers are cached in Redis. Disable when plugins are installed " + "by a system other than this one, which cannot invalidate the cache when a tenant's plugins change.", + default=True, + ) + PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field( description="TTL in seconds for caching tenant plugin model providers in Redis", default=60 * 60 * 24, diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index 986ebd29738..f47861cf274 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_config as _agent_config from .plugin import agent_drive as _agent_drive from .plugin import plugin as _plugin -from .workspace import plugin_model_providers as _plugin_model_providers from .workspace import workspace as _workspace api.add_namespace(inner_api_ns) @@ -36,7 +35,6 @@ __all__ = [ "_knowledge_retrieval", "_mail", "_plugin", - "_plugin_model_providers", "_runtime_credentials", "_workspace", "api", diff --git a/api/controllers/inner_api/workspace/plugin_model_providers.py b/api/controllers/inner_api/workspace/plugin_model_providers.py deleted file mode 100644 index 50008a5bd82..00000000000 --- a/api/controllers/inner_api/workspace/plugin_model_providers.py +++ /dev/null @@ -1,39 +0,0 @@ -from flask_restx import Resource -from pydantic import BaseModel, ConfigDict, Field - -from controllers.common.schema import register_schema_model -from controllers.console.wraps import setup_required -from controllers.inner_api import inner_api_ns -from controllers.inner_api.wraps import enterprise_inner_api_only -from core.plugin.plugin_service import PluginService - - -class InvalidatePluginModelProvidersCachePayload(BaseModel): - model_config = ConfigDict(extra="forbid") - - tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated") - - -register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload) - - -@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate") -class EnterprisePluginModelProvidersCacheInvalidate(Resource): - @setup_required - @enterprise_inner_api_only - @inner_api_ns.doc( - "enterprise_invalidate_plugin_model_providers_cache", - responses={ - 200: "Cache invalidated", - 400: "Invalid request", - 401: "Unauthorized - invalid API key", - }, - ) - @inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__]) - def post(self): - args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {}) - - for tenant_id in args.tenant_ids: - PluginService.invalidate_plugin_model_providers_cache(tenant_id) - - return {"result": "success"}, 200 diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 89274b635ac..e2cf702c5bd 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -434,14 +434,18 @@ class PluginService: exc_info=True, ) + @classmethod + def _fetch_plugin_model_providers_uncached( + cls, tenant_id: str, client: PluginModelClient | None + ) -> tuple[ProviderEntity, ...]: + model_client = client or PluginModelClient() + return tuple(cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)) + @classmethod def _fetch_and_cache_plugin_model_providers( cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None ) -> tuple[ProviderEntity, ...]: - model_client = client or PluginModelClient() - providers = tuple( - cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id) - ) + providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client) generation = cls._load_plugin_model_providers_generation(tenant_id) if generation is not None and generation == refresh_generation: cls._store_cached_plugin_model_providers(tenant_id, generation, providers) @@ -471,6 +475,9 @@ class PluginService: are intentionally owned by this service so tenant isolation and cache expiry are handled in one place. """ + if not dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: + return cls._fetch_plugin_model_providers_uncached(tenant_id, client) + deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT while True: diff --git a/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py b/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py deleted file mode 100644 index 25902117ce5..00000000000 --- a/api/tests/unit_tests/controllers/inner_api/workspace/test_plugin_model_providers.py +++ /dev/null @@ -1,64 +0,0 @@ -import inspect -from unittest.mock import call, patch - -import pytest -from flask import Flask -from pydantic import ValidationError - -from controllers.inner_api.workspace.plugin_model_providers import ( - EnterprisePluginModelProvidersCacheInvalidate, - InvalidatePluginModelProvidersCachePayload, -) - - -class TestInvalidatePluginModelProvidersCachePayload: - def test_valid_payload(self): - payload = InvalidatePluginModelProvidersCachePayload.model_validate( - {"tenant_ids": ["tenant-alpha", "tenant-beta"]} - ) - assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"] - - def test_missing_tenant_ids_defaults_to_empty(self): - payload = InvalidatePluginModelProvidersCachePayload.model_validate({}) - assert payload.tenant_ids == [] - - def test_unknown_field_rejected(self): - with pytest.raises(ValidationError): - InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7}) - - -class TestEnterprisePluginModelProvidersCacheInvalidate: - @pytest.fixture - def api_instance(self): - return EnterprisePluginModelProvidersCacheInvalidate() - - def _post(self, api_instance, app: Flask, payload): - unwrapped_post = inspect.unwrap(api_instance.post) - with app.test_request_context(): - with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns: - mock_ns.payload = payload - return unwrapped_post(api_instance) - - @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") - def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask): - result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]}) - - assert result == ({"result": "success"}, 200) - assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [ - call("tenant-alpha"), - call("tenant-beta"), - ] - - @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") - def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask): - result = self._post(api_instance, app, {"tenant_ids": []}) - - assert result == ({"result": "success"}, 200) - mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called() - - @patch("controllers.inner_api.workspace.plugin_model_providers.PluginService") - def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask): - result = self._post(api_instance, app, None) - - assert result == ({"result": "success"}, 200) - mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called() diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index 278898926b9..b33fe27e075 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -246,6 +246,26 @@ class TestPluginModelProviderCache: call([cache_key]), ] + def test_fetch_plugin_model_providers_bypasses_redis_when_cache_disabled(self) -> None: + """With the cache disabled the daemon is the only source, and Redis is never touched.""" + with patch(f"{MODULE}.redis_client") as redis_client, patch(f"{MODULE}.dify_config") as config: + config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED = False + client = Mock() + client.fetch_model_providers.return_value = [_build_plugin_model_provider()] + + from core.plugin.plugin_service import PluginService + + first = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) + second = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client) + + assert [provider.provider for provider in first] == ["langgenius/openai/openai"] + assert [provider.provider for provider in second] == ["langgenius/openai/openai"] + assert client.fetch_model_providers.call_count == 2 + redis_client.get.assert_not_called() + redis_client.mget.assert_not_called() + redis_client.setex.assert_not_called() + redis_client.lock.assert_not_called() + def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None: """Redis read failures do not block provider discovery for the tenant.""" with patch(f"{MODULE}.redis_client") as redis_client: diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 5fe6ab974e1..dbbe76c6a47 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -73,6 +73,7 @@ SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128 PGDATA=/var/lib/postgresql/data/pgdata PLUGIN_MAX_PACKAGE_SIZE=52428800 PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600 +PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400 # Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users. # Example: langgenius/openai,langgenius/gemini From 59b879d2dfd3957160edecf4d35ac24d1c7032b8 Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Tue, 28 Jul 2026 09:09:25 +0800 Subject: [PATCH 065/531] chore: bump version to 1.16.1 (#39653) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 4 ++-- cli/package.json | 2 +- dify-agent/pyproject.toml | 2 +- dify-agent/uv.lock | 2 +- docker/docker-compose-template.yaml | 14 +++++++------- docker/docker-compose.yaml | 14 +++++++------- web/package.json | 2 +- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 02782d4eec0..5ebe5d610c4 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-api" -version = "1.16.0" +version = "1.16.1" requires-python = "~=3.12.0" dependencies = [ diff --git a/api/uv.lock b/api/uv.lock index 2333a5b710f..676de350ecb 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1281,7 +1281,7 @@ wheels = [ [[package]] name = "dify-agent" -version = "1.16.0" +version = "1.16.1" source = { editable = "../dify-agent" } dependencies = [ { name = "httpx" }, @@ -1331,7 +1331,7 @@ docs = [ [[package]] name = "dify-api" -version = "1.16.0" +version = "1.16.1" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, diff --git a/cli/package.json b/cli/package.json index f1e0be53a78..8b8f8d027cc 100644 --- a/cli/package.json +++ b/cli/package.json @@ -71,7 +71,7 @@ "channel": "alpha", "compat": { "minDify": "1.16.0", - "maxDify": "1.16.0" + "maxDify": "1.16.1" }, "release": { "tagPrefix": "difyctl-v", diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index a496d19cdd1..50bc1a93165 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-agent" -version = "1.16.0" +version = "1.16.1" description = "Add your description here" readme = "README.md" requires-python = ">=3.12,<4.0" diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index 50de0e58c66..8c2ee1c876f 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -581,7 +581,7 @@ wheels = [ [[package]] name = "dify-agent" -version = "1.16.0" +version = "1.16.1" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 016177008b0..9aebfd80f6e 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -220,7 +220,7 @@ services: # API service api: <<: *shared-api-worker-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 environment: MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -271,7 +271,7 @@ services: # WebSocket service for workflow collaboration. api_websocket: <<: *shared-api-worker-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 profiles: - collaboration environment: @@ -297,7 +297,7 @@ services: # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: <<: *shared-worker-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 environment: MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -347,7 +347,7 @@ services: # Celery beat for scheduling periodic tasks. worker_beat: <<: *shared-worker-beat-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 environment: MODE: beat depends_on: @@ -380,7 +380,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.16.0 + image: langgenius/dify-web:1.16.1 restart: always env_file: - path: ./envs/core-services/web.env @@ -542,7 +542,7 @@ services: # on port 3128, which only allows agent_backend /agent-stub/ and the Dify API # /files/* endpoints (see ssrf_proxy/squid-agent.conf.template). local_sandbox: - image: langgenius/dify-agent-local-sandbox:1.16.0 + image: langgenius/dify-agent-local-sandbox:1.16.1 restart: always env_file: - path: ./envs/core-services/local-sandbox.env @@ -651,7 +651,7 @@ services: # Dify Agent backend service. agent_backend: - image: langgenius/dify-agent-backend:1.16.0 + image: langgenius/dify-agent-backend:1.16.1 restart: always env_file: - path: ./envs/core-services/dify-agent.env diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index e95fce741b7..3e1384cc5ee 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -226,7 +226,7 @@ services: # API service api: <<: *shared-api-worker-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 environment: MODE: api SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -277,7 +277,7 @@ services: # WebSocket service for workflow collaboration. api_websocket: <<: *shared-api-worker-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 profiles: - collaboration environment: @@ -303,7 +303,7 @@ services: # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: <<: *shared-worker-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 environment: MODE: worker SENTRY_DSN: ${API_SENTRY_DSN:-} @@ -353,7 +353,7 @@ services: # Celery beat for scheduling periodic tasks. worker_beat: <<: *shared-worker-beat-config - image: langgenius/dify-api:1.16.0 + image: langgenius/dify-api:1.16.1 environment: MODE: beat depends_on: @@ -386,7 +386,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.16.0 + image: langgenius/dify-web:1.16.1 restart: always env_file: - path: ./envs/core-services/web.env @@ -548,7 +548,7 @@ services: # on port 3128, which only allows agent_backend /agent-stub/ and the Dify API # /files/* endpoints (see ssrf_proxy/squid-agent.conf.template). local_sandbox: - image: langgenius/dify-agent-local-sandbox:1.16.0 + image: langgenius/dify-agent-local-sandbox:1.16.1 restart: always env_file: - path: ./envs/core-services/local-sandbox.env @@ -657,7 +657,7 @@ services: # Dify Agent backend service. agent_backend: - image: langgenius/dify-agent-backend:1.16.0 + image: langgenius/dify-agent-backend:1.16.1 restart: always env_file: - path: ./envs/core-services/dify-agent.env diff --git a/web/package.json b/web/package.json index d4fb07f38c7..05f92154052 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "dify-web", - "version": "1.16.0", + "version": "1.16.1", "private": true, "type": "module", "imports": { From 6f8ed69ee15f9a2e7189ca066275e973d091d1e9 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Tue, 28 Jul 2026 10:31:18 +0800 Subject: [PATCH 066/531] fix: fix mcp output_schema is optional (#39453) Co-authored-by: yunlu.wen --- api/core/tools/mcp_tool/tool.py | 2 ++ api/tests/unit_tests/tools/test_mcp_tool.py | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/api/core/tools/mcp_tool/tool.py b/api/core/tools/mcp_tool/tool.py index 07c9ff63b30..d5b62a61a9c 100644 --- a/api/core/tools/mcp_tool/tool.py +++ b/api/core/tools/mcp_tool/tool.py @@ -107,6 +107,8 @@ class MCPTool(Tool): if self.entity.output_schema and result.structuredContent: for k, v in result.structuredContent.items(): yield self.create_variable_message(k, v) + elif result.structuredContent: + yield self.create_json_message(result.structuredContent) def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]: """Process text content and yield appropriate messages.""" diff --git a/api/tests/unit_tests/tools/test_mcp_tool.py b/api/tests/unit_tests/tools/test_mcp_tool.py index eb7bb34dbe4..00a84f71f7f 100644 --- a/api/tests/unit_tests/tools/test_mcp_tool.py +++ b/api/tests/unit_tests/tools/test_mcp_tool.py @@ -135,6 +135,19 @@ class TestMCPToolInvoke: values = {m.message.variable_name: m.message.variable_value for m in var_msgs} assert values == {"a": 1, "b": "x"} + def test_invoke_yields_json_when_structured_content_has_no_output_schema(self, orm_session: Session) -> None: + tool = _make_mcp_tool() + result = CallToolResult(content=[], structuredContent={"a": 1, "b": "x"}) + + with patch.object(tool, "invoke_remote_mcp_tool", return_value=result): + messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={})) + + assert len(messages) == 1 + msg = messages[0] + assert msg.type == ToolInvokeMessage.MessageType.JSON + assert isinstance(msg.message, ToolInvokeMessage.JsonMessage) + assert msg.message.json_object == {"a": 1, "b": "x"} + class TestMCPToolUsageExtraction: """Test usage metadata extraction from MCP tool results.""" From 65ead05dfcd9a745df3087e9c9a578751a638992 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:16:19 +0900 Subject: [PATCH 067/531] test: use SQLite sessions in services plugin (#39084) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- .../test_plugin_auto_upgrade_service.py | 301 ++++++++---------- 1 file changed, 137 insertions(+), 164 deletions(-) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py index e66bb3fff04..0296f16dd2c 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_auto_upgrade_service.py @@ -1,89 +1,105 @@ import logging from types import SimpleNamespace from unittest.mock import MagicMock, patch +from uuid import uuid4 import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session from models.account import ( TenantPluginAutoUpgradeCategory, TenantPluginAutoUpgradeMode, + TenantPluginAutoUpgradeStrategy, TenantPluginAutoUpgradeStrategySetting, ) +from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService MODULE = "services.plugin.plugin_auto_upgrade_service" PLUGIN_CATEGORY = TenantPluginAutoUpgradeCategory.TOOL +STRATEGY_MODELS = (TenantPluginAutoUpgradeStrategy,) -def _patched_session(): - """Return a mock SQLAlchemy session for service calls.""" - session = MagicMock() - return session +def _strategy( + tenant_id: str, + *, + category: TenantPluginAutoUpgradeCategory = PLUGIN_CATEGORY, + setting: TenantPluginAutoUpgradeStrategySetting = TenantPluginAutoUpgradeStrategySetting.FIX_ONLY, + mode: TenantPluginAutoUpgradeMode = TenantPluginAutoUpgradeMode.EXCLUDE, + exclude: list[str] | None = None, + include: list[str] | None = None, + upgrade_time: int = 0, +) -> TenantPluginAutoUpgradeStrategy: + return TenantPluginAutoUpgradeStrategy( + tenant_id=tenant_id, + category=category, + strategy_setting=setting, + upgrade_time_of_day=upgrade_time, + upgrade_mode=mode, + exclude_plugins=exclude or [], + include_plugins=include or [], + ) class TestGetStrategy: - def test_returns_strategy_when_found(self): - session = _patched_session() - strategy = MagicMock() - session.scalar.return_value = strategy + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_returns_strategy_when_found(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + strategy = _strategy(tenant_id) + sqlite_session.add(strategy) + sqlite_session.commit() - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session) + result = PluginAutoUpgradeService.get_strategy(tenant_id, PLUGIN_CATEGORY, session=sqlite_session) assert result is strategy - def test_returns_none_when_not_found(self): - session = _patched_session() - session.scalar.return_value = None - - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY, session=session) - - assert result is None + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_returns_none_when_not_found(self, sqlite_session: Session) -> None: + assert PluginAutoUpgradeService.get_strategy(str(uuid4()), PLUGIN_CATEGORY, session=sqlite_session) is None class TestChangeStrategy: - def test_creates_new_strategy(self): - session = _patched_session() - session.scalar.return_value = None - - with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: - strat_cls.return_value = MagicMock() - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - result = PluginAutoUpgradeService.change_strategy( - "t1", - TenantPluginAutoUpgradeStrategySetting.FIX_ONLY, - 3, - TenantPluginAutoUpgradeMode.ALL, - [], - [], - category=PLUGIN_CATEGORY, - session=session, - ) - - assert result is True - session.add.assert_called_once() - - def test_updates_existing_strategy(self): - session = _patched_session() - existing = MagicMock() - session.scalar.return_value = existing - - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_creates_new_strategy(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) result = PluginAutoUpgradeService.change_strategy( - "t1", + tenant_id, + TenantPluginAutoUpgradeStrategySetting.FIX_ONLY, + 3, + TenantPluginAutoUpgradeMode.ALL, + [], + [], + category=PLUGIN_CATEGORY, + session=sqlite_session, + ) + + strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy)) + assert result is True + assert strategy is not None + assert strategy.tenant_id == tenant_id + assert strategy.upgrade_time_of_day == 3 + assert strategy.upgrade_mode == TenantPluginAutoUpgradeMode.ALL + + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_updates_existing_strategy(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + existing = _strategy(tenant_id) + sqlite_session.add(existing) + sqlite_session.commit() + + result = PluginAutoUpgradeService.change_strategy( + tenant_id, TenantPluginAutoUpgradeStrategySetting.LATEST, 5, TenantPluginAutoUpgradeMode.PARTIAL, ["p1"], ["p2"], category=PLUGIN_CATEGORY, - session=session, + session=sqlite_session, ) + sqlite_session.refresh(existing) assert result is True assert existing.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST assert existing.upgrade_time_of_day == 5 @@ -93,157 +109,115 @@ class TestChangeStrategy: class TestExcludePlugin: - def test_creates_default_strategy_when_none_exists(self): - session = _patched_session() - session.scalar.return_value = None + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_creates_default_strategy_when_none_exists(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) - with ( - patch(f"{MODULE}.select"), - patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy"), - ): - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - result = PluginAutoUpgradeService.exclude_plugin( - "t1", - "plugin-1", - PLUGIN_CATEGORY, - session=session, - ) + result = PluginAutoUpgradeService.exclude_plugin(tenant_id, "plugin-1", PLUGIN_CATEGORY, session=sqlite_session) + strategy = sqlite_session.scalar(select(TenantPluginAutoUpgradeStrategy)) assert result is True - session.add.assert_called_once() + assert strategy is not None + assert strategy.exclude_plugins == ["plugin-1"] - def test_appends_to_exclude_list_in_exclude_mode(self): - session = _patched_session() - existing = MagicMock() - existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE - existing.exclude_plugins = ["p-existing"] - session.scalar.return_value = existing + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_appends_to_exclude_list_in_exclude_mode(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + existing = _strategy(tenant_id, exclude=["p-existing"]) + sqlite_session.add(existing) + sqlite_session.commit() - with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: - strat_cls.UpgradeMode.EXCLUDE = "exclude" - strat_cls.UpgradeMode.PARTIAL = "partial" - strat_cls.UpgradeMode.ALL = "all" - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + PluginAutoUpgradeService.exclude_plugin(tenant_id, "p-new", PLUGIN_CATEGORY, session=sqlite_session) - result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY, session=session) - - assert result is True + sqlite_session.refresh(existing) assert existing.exclude_plugins == ["p-existing", "p-new"] - def test_removes_from_include_list_in_partial_mode(self): - session = _patched_session() - existing = MagicMock() - existing.upgrade_mode = TenantPluginAutoUpgradeMode.PARTIAL - existing.include_plugins = ["p1", "p2"] - session.scalar.return_value = existing + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_removes_from_include_list_in_partial_mode(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.PARTIAL, include=["p1", "p2"]) + sqlite_session.add(existing) + sqlite_session.commit() - with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: - strat_cls.UpgradeMode.EXCLUDE = "exclude" - strat_cls.UpgradeMode.PARTIAL = "partial" - strat_cls.UpgradeMode.ALL = "all" - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session) - result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session) - - assert result is True + sqlite_session.refresh(existing) assert existing.include_plugins == ["p2"] - def test_switches_to_exclude_mode_from_all(self): - session = _patched_session() - existing = MagicMock() - existing.upgrade_mode = TenantPluginAutoUpgradeMode.ALL - session.scalar.return_value = existing + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_switches_to_exclude_mode_from_all(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + existing = _strategy(tenant_id, mode=TenantPluginAutoUpgradeMode.ALL) + sqlite_session.add(existing) + sqlite_session.commit() - with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: - strat_cls.UpgradeMode.EXCLUDE = "exclude" - strat_cls.UpgradeMode.PARTIAL = "partial" - strat_cls.UpgradeMode.ALL = "all" - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService + PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session) - result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session) - - assert result is True + sqlite_session.refresh(existing) assert existing.upgrade_mode == TenantPluginAutoUpgradeMode.EXCLUDE assert existing.exclude_plugins == ["p1"] - def test_no_duplicate_in_exclude_list(self): - session = _patched_session() - existing = MagicMock() - existing.upgrade_mode = TenantPluginAutoUpgradeMode.EXCLUDE - existing.exclude_plugins = ["p1"] - session.scalar.return_value = existing + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_no_duplicate_in_exclude_list(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + existing = _strategy(tenant_id, exclude=["p1"]) + sqlite_session.add(existing) + sqlite_session.commit() - with patch(f"{MODULE}.select"), patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls: - strat_cls.UpgradeMode.EXCLUDE = "exclude" - strat_cls.UpgradeMode.PARTIAL = "partial" - strat_cls.UpgradeMode.ALL = "all" - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY, session=session) + PluginAutoUpgradeService.exclude_plugin(tenant_id, "p1", PLUGIN_CATEGORY, session=sqlite_session) + sqlite_session.refresh(existing) assert existing.exclude_plugins == ["p1"] class TestBackfillStrategyCategories: - def test_creates_default_missing_categories_without_fetching_daemon(self): - session = _patched_session() - tool_strategy = SimpleNamespace( - category=TenantPluginAutoUpgradeCategory.TOOL, - strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY, - upgrade_time_of_day=0, - upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE, - exclude_plugins=[], - include_plugins=[], - ) - session.scalars.return_value.all.return_value = [tool_strategy] + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_creates_default_missing_categories_without_fetching_daemon(self, sqlite_session: Session) -> None: + tenant_id = str(uuid4()) + tool_strategy = _strategy(tenant_id) + sqlite_session.add(tool_strategy) + sqlite_session.commit() installer = MagicMock() with patch(f"{MODULE}.PluginInstaller", return_value=installer): - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session) - expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1") + result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session) + expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id) + strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all()) assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 1 assert result.normalized is False installer.list_plugins.assert_not_called() + assert len(strategies) == len(TenantPluginAutoUpgradeCategory) assert tool_strategy.upgrade_time_of_day == expected_time - created_strategies = [call.args[0] for call in session.add.call_args_list] model_strategy = next( - strategy for strategy in created_strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL + strategy for strategy in strategies if strategy.category == TenantPluginAutoUpgradeCategory.MODEL ) assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategySetting.LATEST assert model_strategy.upgrade_time_of_day == expected_time - def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self): - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1") - + def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self) -> None: + default_time = PluginAutoUpgradeService.default_upgrade_time_of_day(str(uuid4())) assert default_time % (15 * 60) == 0 assert 0 <= default_time < 24 * 60 * 60 - def test_creates_missing_categories_and_splits_known_plugins(self, caplog: pytest.LogCaptureFixture): - session = _patched_session() - tool_strategy = SimpleNamespace( - category=TenantPluginAutoUpgradeCategory.TOOL, - strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY, - upgrade_time_of_day=0, - upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE, - exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"], - include_plugins=["model-plugin", "tool-plugin"], + @pytest.mark.parametrize("sqlite_session", [STRATEGY_MODELS], indirect=True) + def test_creates_missing_categories_and_splits_known_plugins( + self, sqlite_session: Session, caplog: pytest.LogCaptureFixture + ) -> None: + tenant_id = str(uuid4()) + tool_strategy = _strategy( + tenant_id, + exclude=["tool-plugin", "model-plugin", "unknown-plugin"], + include=["model-plugin", "tool-plugin"], ) - model_strategy = SimpleNamespace( + model_strategy = _strategy( + tenant_id, category=TenantPluginAutoUpgradeCategory.MODEL, - strategy_setting=TenantPluginAutoUpgradeStrategySetting.FIX_ONLY, - upgrade_time_of_day=0, - upgrade_mode=TenantPluginAutoUpgradeMode.EXCLUDE, - exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"], - include_plugins=["model-plugin", "tool-plugin"], + exclude=["tool-plugin", "model-plugin", "unknown-plugin"], + include=["model-plugin", "tool-plugin"], ) - session.scalars.return_value.all.return_value = [tool_strategy, model_strategy] - + sqlite_session.add_all([tool_strategy, model_strategy]) + sqlite_session.commit() installed_plugins = [ SimpleNamespace( plugin_id="tool-plugin", @@ -261,18 +235,17 @@ class TestBackfillStrategyCategories: patch(f"{MODULE}.PluginInstaller", return_value=installer), caplog.at_level(logging.WARNING, logger=MODULE), ): - from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService - - result = PluginAutoUpgradeService.backfill_strategy_categories("t1", session=session) + result = PluginAutoUpgradeService.backfill_strategy_categories(tenant_id, session=sqlite_session) + strategies = list(sqlite_session.scalars(select(TenantPluginAutoUpgradeStrategy)).all()) assert result.created_count == len(TenantPluginAutoUpgradeCategory) - 2 assert result.normalized is True - assert session.add.call_count == len(TenantPluginAutoUpgradeCategory) - 2 + assert len(strategies) == len(TenantPluginAutoUpgradeCategory) assert tool_strategy.exclude_plugins == ["tool-plugin"] assert tool_strategy.include_plugins == ["tool-plugin"] assert model_strategy.exclude_plugins == ["model-plugin"] assert model_strategy.include_plugins == ["model-plugin"] assert ( "Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: " - "tenant_id=t1, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages + f"tenant_id={tenant_id}, field=exclude_plugins, plugin_ids=['unknown-plugin']" in caplog.messages ) From b597bb1b177d5d6f7d93796b70cf209dfc62649e Mon Sep 17 00:00:00 2001 From: Escape0707 Date: Tue, 28 Jul 2026 12:18:01 +0900 Subject: [PATCH 068/531] test: separate human input unit and database paths (#39655) --- .../services/test_human_input_service.py | 224 ++++++------------ 1 file changed, 78 insertions(+), 146 deletions(-) diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py index 55dd86129bb..78e1b056d2f 100644 --- a/api/tests/unit_tests/services/test_human_input_service.py +++ b/api/tests/unit_tests/services/test_human_input_service.py @@ -1,6 +1,5 @@ import dataclasses import logging -from collections.abc import Iterator from datetime import datetime, timedelta from unittest.mock import MagicMock @@ -42,14 +41,13 @@ from services.human_input_service import ( @pytest.fixture -def sqlite_session_factory(sqlite_engine: Engine) -> Iterator[tuple[sessionmaker[Session], Session]]: - factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - with factory() as session: - yield factory, session +def unbound_session_factory() -> sessionmaker[Session]: + """Supply the required constructor dependency without enabling database access.""" + return sessionmaker() -def _persist_app(sqlite_session: Session, mode: AppMode) -> App: - app = App( +def _make_app(mode: AppMode) -> App: + return App( id="app-id", tenant_id="tenant-id", name="Test App", @@ -60,9 +58,6 @@ def _persist_app(sqlite_session: Session, mode: AppMode) -> App: enable_api=True, max_active_requests=0, ) - sqlite_session.add(app) - sqlite_session.commit() - return app @pytest.fixture @@ -97,14 +92,11 @@ def sample_form_record(): ) -@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) def test_enqueue_resume_dispatches_task_for_workflow( mocker: MockerFixture, - sqlite_session_factory, - sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], ): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) + service = HumanInputService(sqlite_session_factory) workflow_run = MagicMock() workflow_run.app_id = "app-id" @@ -116,7 +108,8 @@ def test_enqueue_resume_dispatches_task_for_workflow( return_value=workflow_run_repo, ) - _persist_app(sqlite_session, AppMode.WORKFLOW) + with sqlite_session_factory.begin() as arrange_session: + arrange_session.add(_make_app(AppMode.WORKFLOW)) resume_task = mocker.patch("services.human_input_service.resume_app_execution") @@ -128,10 +121,9 @@ def test_enqueue_resume_dispatches_task_for_workflow( def test_ensure_form_active_respects_global_timeout( - monkeypatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory + monkeypatch, sample_form_record: HumanInputFormRecord, unbound_session_factory ): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) + service = HumanInputService(unbound_session_factory) expired_record = dataclasses.replace( sample_form_record, created_at=naive_utc_now() - timedelta(hours=2), @@ -143,14 +135,11 @@ def test_ensure_form_active_respects_global_timeout( service.ensure_form_active(Form(expired_record)) -@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) def test_enqueue_resume_dispatches_task_for_advanced_chat( mocker: MockerFixture, - sqlite_session_factory, - sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], ): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) + service = HumanInputService(sqlite_session_factory) workflow_run = MagicMock() workflow_run.app_id = "app-id" @@ -162,7 +151,8 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat( return_value=workflow_run_repo, ) - _persist_app(sqlite_session, AppMode.ADVANCED_CHAT) + with sqlite_session_factory.begin() as arrange_session: + arrange_session.add(_make_app(AppMode.ADVANCED_CHAT)) resume_task = mocker.patch("services.human_input_service.resume_app_execution") @@ -173,14 +163,11 @@ def test_enqueue_resume_dispatches_task_for_advanced_chat( assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id" -@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) def test_enqueue_resume_skips_unsupported_app_mode( mocker: MockerFixture, - sqlite_session_factory, - sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], ): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) + service = HumanInputService(sqlite_session_factory) workflow_run = MagicMock() workflow_run.app_id = "app-id" @@ -192,7 +179,8 @@ def test_enqueue_resume_skips_unsupported_app_mode( return_value=workflow_run_repo, ) - _persist_app(sqlite_session, AppMode.COMPLETION) + with sqlite_session_factory.begin() as arrange_session: + arrange_session.add(_make_app(AppMode.COMPLETION)) resume_task = mocker.patch("services.human_input_service.resume_app_execution") @@ -202,14 +190,13 @@ def test_enqueue_resume_skips_unsupported_app_mode( def test_get_form_definition_by_token_for_console_uses_repository( - sample_form_record: HumanInputFormRecord, sqlite_session_factory + sample_form_record: HumanInputFormRecord, unbound_session_factory ): - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE) repo.get_by_token.return_value = console_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) form = service.get_form_definition_by_token_for_console("token") repo.get_by_token.assert_called_once_with("token") @@ -245,9 +232,8 @@ def _build_resumption_context_state(*, options: list[str], workflow_run_id: str) def test_resolve_form_inputs_uses_runtime_select_options( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ): - session_factory, _ = sqlite_session_factory configured_input = SelectInputConfig( output_variable_name="decision", option_source=StringListSource( @@ -272,7 +258,7 @@ def test_resolve_form_inputs_uses_runtime_select_options( "services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository", return_value=workflow_run_repo, ) - service = HumanInputService(session_factory) + service = HumanInputService(unbound_session_factory) resolved_inputs = service.resolve_form_inputs(Form(record)) @@ -284,13 +270,12 @@ def test_resolve_form_inputs_uses_runtime_select_options( def test_submit_form_by_token_calls_repository_and_enqueue( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ): - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record repo.mark_submitted.return_value = sample_form_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) enqueue_spy = mocker.patch.object(service, "enqueue_resume") service.submit_form_by_token( @@ -313,11 +298,10 @@ def test_submit_form_by_token_calls_repository_and_enqueue( def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form( - sample_form_record, sqlite_session_factory, mocker: MockerFixture + sample_form_record, unbound_session_factory, mocker: MockerFixture ): # ENG-635: a conversation-owned (Agent v2 chat) form routes to the chat # resume, not the workflow resume. - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) conversation_record = dataclasses.replace( sample_form_record, @@ -326,7 +310,7 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form( ) repo.get_by_token.return_value = conversation_record repo.mark_submitted.return_value = conversation_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) workflow_enqueue_spy = mocker.patch.object(service, "enqueue_resume") chat_enqueue_spy = mocker.patch.object(service, "enqueue_agent_app_resume") @@ -343,9 +327,8 @@ def test_submit_form_by_token_enqueues_agent_app_resume_for_conversation_form( def test_submit_form_by_token_skips_enqueue_for_delivery_test( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ): - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) test_record = dataclasses.replace( sample_form_record, @@ -354,7 +337,7 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test( ) repo.get_by_token.return_value = test_record repo.mark_submitted.return_value = test_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) enqueue_spy = mocker.patch.object(service, "enqueue_resume") service.submit_form_by_token( @@ -368,13 +351,12 @@ def test_submit_form_by_token_skips_enqueue_for_delivery_test( def test_submit_form_by_token_passes_submission_user_id( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ): - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record repo.mark_submitted.return_value = sample_form_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) enqueue_spy = mocker.patch.object(service, "enqueue_resume") service.submit_form_by_token( @@ -391,11 +373,10 @@ def test_submit_form_by_token_passes_submission_user_id( enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id) -def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, sqlite_session_factory): - session_factory, _ = sqlite_session_factory +def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormRecord, unbound_session_factory): repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = dataclasses.replace(sample_form_record) - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) with pytest.raises(InvalidFormDataError) as exc_info: service.submit_form_by_token( @@ -409,8 +390,7 @@ def test_submit_form_by_token_invalid_action(sample_form_record: HumanInputFormR repo.mark_submitted.assert_not_called() -def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, sqlite_session_factory): - session_factory, _ = sqlite_session_factory +def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormRecord, unbound_session_factory): repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition_with_input = FormDefinition( @@ -422,7 +402,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR ) form_with_input = dataclasses.replace(sample_form_record, definition=definition_with_input) repo.get_by_token.return_value = form_with_input - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) with pytest.raises(InvalidFormDataError) as exc_info: service.submit_form_by_token( @@ -436,42 +416,6 @@ def test_submit_form_by_token_missing_inputs(sample_form_record: HumanInputFormR repo.mark_submitted.assert_not_called() -def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlite_session_factory): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) - definition = FormDefinition.model_validate( - { - "form_content": "Pick one and upload files", - "inputs": [ - { - "type": "select", - "output_variable_name": "decision", - "option_source": { - "type": "constant", - "value": ["approve", "reject"], - }, - }, - { - "type": "file", - "output_variable_name": "attachment", - "allowed_file_types": ["document"], - "allowed_file_upload_methods": ["remote_url"], - }, - { - "type": "file-list", - "output_variable_name": "attachments", - "allowed_file_types": ["document"], - "allowed_file_upload_methods": ["remote_url"], - "number_limits": 3, - }, - ], - "user_actions": [{"id": "submit", "title": "Submit"}], - "rendered_content": "

Pick one and upload files

", - "expiration_time": naive_utc_now() + timedelta(hours=1), - } - ) - - @pytest.mark.parametrize( ("input_definition", "submitted_value", "expected_message"), [ @@ -522,12 +466,11 @@ def test_validate_human_input_submission_accepts_select_file_and_file_list(sqlit ) def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads( sample_form_record, - sqlite_session_factory, + unbound_session_factory, input_definition, submitted_value, expected_message, ): - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition.model_validate( { @@ -539,7 +482,7 @@ def test_validate_human_input_submission_rejects_invalid_select_and_file_payload } ) repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) with pytest.raises(InvalidFormDataError) as exc_info: service.submit_form_by_token( @@ -569,7 +512,7 @@ def test_form_properties(sample_form_record: HumanInputFormRecord): def test_form_submitted_error_init(): error = FormSubmittedError(form_id="test-form") - assert "form_id=test-form" in error.description + assert error.description == "This form has already been submitted by another user, form_id=test-form" assert error.code == 412 @@ -580,61 +523,55 @@ def test_human_input_service_init_with_engine(sqlite_engine: Engine): assert service._session_factory.kw["bind"] is sqlite_engine -def test_get_form_by_token_none(sqlite_session_factory): - session_factory, _ = sqlite_session_factory +def test_get_form_by_token_none(unbound_session_factory): repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = None - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) assert service.get_form_by_token("invalid") is None -def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, sqlite_session_factory): - session_factory, _ = sqlite_session_factory +def test_get_form_definition_by_token_mismatch(sample_form_record: HumanInputFormRecord, unbound_session_factory): repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) # RecipientType mismatch assert service.get_form_definition_by_token(RecipientType.CONSOLE, "token") is None -def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, sqlite_session_factory): - session_factory, _ = sqlite_session_factory +def test_get_form_definition_by_token_success(sample_form_record: HumanInputFormRecord, unbound_session_factory): repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) form = service.get_form_definition_by_token(RecipientType.STANDALONE_WEB_APP, "token") assert form is not None assert form.id == sample_form_record.form_id def test_get_form_definition_by_token_for_console_mismatch( - sample_form_record: HumanInputFormRecord, sqlite_session_factory + sample_form_record: HumanInputFormRecord, unbound_session_factory ): - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record # is STANDALONE_WEB_APP - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) assert service.get_form_definition_by_token_for_console("token") is None -def test_submit_form_by_token_delivery_not_enabled(sqlite_session_factory): - session_factory, _ = sqlite_session_factory +def test_submit_form_by_token_delivery_not_enabled(unbound_session_factory): repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = None - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) with pytest.raises(human_input_service_module.WebAppDeliveryNotEnabledError): service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "action", {}) def test_submit_form_by_token_no_workflow_run_id( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ): - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) repo.get_by_token.return_value = sample_form_record @@ -642,16 +579,15 @@ def test_submit_form_by_token_no_workflow_run_id( result_record = dataclasses.replace(sample_form_record, workflow_run_id=None) repo.mark_submitted.return_value = result_record - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) enqueue_spy = mocker.patch.object(service, "enqueue_resume") service.submit_form_by_token(RecipientType.STANDALONE_WEB_APP, "token", "submit", {}) enqueue_spy.assert_not_called() -def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sqlite_session_factory): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) +def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, unbound_session_factory): + service = HumanInputService(unbound_session_factory) # Submitted submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now()) @@ -671,18 +607,16 @@ def test_ensure_form_active_errors(sample_form_record: HumanInputFormRecord, sql service.ensure_form_active(Form(expired_time_record)) -def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, sqlite_session_factory): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) +def test_ensure_not_submitted_raises(sample_form_record: HumanInputFormRecord, unbound_session_factory): + service = HumanInputService(unbound_session_factory) submitted_record = dataclasses.replace(sample_form_record, submitted_at=naive_utc_now()) with pytest.raises(human_input_service_module.FormSubmittedError): service._ensure_not_submitted(Form(submitted_record)) -def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session_factory): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) +def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, unbound_session_factory): + service = HumanInputService(unbound_session_factory) workflow_run_repo = MagicMock() workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = None @@ -696,15 +630,12 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, sqlite_session assert "WorkflowRun not found" in str(excinfo.value) -@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) def test_enqueue_resume_app_not_found( mocker, - sqlite_session_factory, - sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], caplog: pytest.LogCaptureFixture, ): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) + service = HumanInputService(sqlite_session_factory) workflow_run = MagicMock() workflow_run.app_id = "app-id" @@ -715,26 +646,31 @@ def test_enqueue_resume_app_not_found( "services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository", return_value=workflow_run_repo, ) + resume_task = mocker.patch("services.human_input_service.resume_app_execution") with caplog.at_level(logging.ERROR, logger="services.human_input_service"): service.enqueue_resume("workflow-run-id") - assert any(r.levelno >= logging.ERROR for r in caplog.records) + + assert ( + "services.human_input_service", + logging.ERROR, + "App not found for WorkflowRun, workflow_run_id=workflow-run-id, app_id=app-id", + ) in caplog.record_tuples + resume_task.apply_async.assert_not_called() def test_is_globally_expired_zero_timeout( - monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, sqlite_session_factory + monkeypatch: pytest.MonkeyPatch, sample_form_record: HumanInputFormRecord, unbound_session_factory ): - session_factory, _ = sqlite_session_factory - service = HumanInputService(session_factory) + service = HumanInputService(unbound_session_factory) monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0) assert service._is_globally_expired(Form(sample_form_record)) is False def test_submit_form_by_token_normalizes_select_and_files( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ) -> None: - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( form_content="hello", @@ -753,7 +689,7 @@ def test_submit_form_by_token_normalizes_select_and_files( form_with_inputs = dataclasses.replace(sample_form_record, definition=definition) repo.get_by_token.return_value = form_with_inputs repo.mark_submitted.return_value = form_with_inputs - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) single_file = File( file_id="file-1", @@ -815,9 +751,8 @@ def test_submit_form_by_token_normalizes_select_and_files( def test_submit_form_by_token_invalid_select_value( - sample_form_record: HumanInputFormRecord, sqlite_session_factory + sample_form_record: HumanInputFormRecord, unbound_session_factory ) -> None: - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( form_content="hello", @@ -832,7 +767,7 @@ def test_submit_form_by_token_invalid_select_value( expiration_time=sample_form_record.expiration_time, ) repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"): service.submit_form_by_token( @@ -844,9 +779,8 @@ def test_submit_form_by_token_invalid_select_value( def test_submit_form_by_token_invalid_file_list_item( - sample_form_record: HumanInputFormRecord, sqlite_session_factory + sample_form_record: HumanInputFormRecord, unbound_session_factory ) -> None: - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( form_content="hello", @@ -856,7 +790,7 @@ def test_submit_form_by_token_invalid_file_list_item( expiration_time=sample_form_record.expiration_time, ) repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) with pytest.raises( InvalidFormDataError, @@ -871,9 +805,8 @@ def test_submit_form_by_token_invalid_file_list_item( def test_submit_form_by_token_rejects_cross_tenant_file( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ) -> None: - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( form_content="hello", @@ -883,7 +816,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file( expiration_time=sample_form_record.expiration_time, ) repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file")) with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment'"): @@ -904,9 +837,8 @@ def test_submit_form_by_token_rejects_cross_tenant_file( def test_submit_form_by_token_rejects_cross_tenant_file_list( - sample_form_record: HumanInputFormRecord, sqlite_session_factory, mocker: MockerFixture + sample_form_record: HumanInputFormRecord, unbound_session_factory, mocker: MockerFixture ) -> None: - session_factory, _ = sqlite_session_factory repo = MagicMock(spec=HumanInputFormSubmissionRepository) definition = FormDefinition( form_content="hello", @@ -916,7 +848,7 @@ def test_submit_form_by_token_rejects_cross_tenant_file_list( expiration_time=sample_form_record.expiration_time, ) repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition) - service = HumanInputService(session_factory, form_repository=repo) + service = HumanInputService(unbound_session_factory, form_repository=repo) mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file")) with pytest.raises( From 1e61078e93aed8dd065cca509ebdb5669fb084e1 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:18:32 +0900 Subject: [PATCH 069/531] test: use SQLite sessions in services core (#39088) --- .../unit_tests/services/test_file_service.py | 235 +++++++++--------- 1 file changed, 111 insertions(+), 124 deletions(-) diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index 583509e20db..0278887e010 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -1,6 +1,8 @@ import base64 import hashlib import os +from collections.abc import Iterator +from datetime import UTC, datetime from unittest.mock import MagicMock, patch import pytest @@ -9,6 +11,8 @@ from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import NotFound from configs import dify_config +from extensions.storage.storage_type import StorageType +from models.base import TypeBase from models.enums import CreatorUserRole from models.model import Account, EndUser, UploadFile from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError @@ -17,31 +21,54 @@ from services.file_service import FileService class TestFileService: @pytest.fixture - def mock_db_session(self): - session = MagicMock(spec=Session) - # Mock context manager behavior - session.__enter__.return_value = session - return session + def sqlite_session_maker(self, sqlite_engine: Engine) -> sessionmaker[Session]: + TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[UploadFile.__tablename__]]) + return sessionmaker(bind=sqlite_engine, expire_on_commit=False) @pytest.fixture - def mock_session_maker(self, mock_db_session): - maker = MagicMock(spec=sessionmaker) - maker.return_value = mock_db_session - return maker + def db_session(self, sqlite_session_maker: sessionmaker[Session]) -> Iterator[Session]: + with sqlite_session_maker() as session: + yield session @pytest.fixture - def file_service(self, mock_session_maker): - return FileService(session_factory=mock_session_maker) + def file_service(self, sqlite_session_maker: sessionmaker[Session]) -> FileService: + return FileService(session_factory=sqlite_session_maker) - def test_init_with_engine(self): - engine = MagicMock(spec=Engine) - service = FileService(session_factory=engine) + @staticmethod + def _persist_upload_file( + session: Session, + *, + file_id: str = "file_id", + tenant_id: str = "tenant_id", + extension: str = "txt", + mime_type: str = "text/plain", + key: str = "key", + ) -> UploadFile: + upload_file = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType.LOCAL, + key=key, + name=f"test.{extension}", + size=10, + extension=extension, + mime_type=mime_type, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="user_id", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + used=False, + ) + upload_file.id = file_id + session.add(upload_file) + session.commit() + return upload_file + + def test_init_with_engine(self, sqlite_engine: Engine): + service = FileService(session_factory=sqlite_engine) assert isinstance(service._session_maker, sessionmaker) - def test_init_with_sessionmaker(self): - maker = MagicMock(spec=sessionmaker) - service = FileService(session_factory=maker) - assert service._session_maker == maker + def test_init_with_sessionmaker(self, sqlite_session_maker: sessionmaker[Session]): + service = FileService(session_factory=sqlite_session_maker) + assert service._session_maker == sqlite_session_maker def test_init_invalid_factory(self): with pytest.raises(AssertionError, match="must be a sessionmaker or an Engine."): @@ -52,11 +79,11 @@ class TestFileService: @patch("services.file_service.extract_tenant_id") @patch("services.file_service.file_helpers.get_signed_file_url") def test_upload_file_success( - self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, mock_db_session + self, mock_get_url, mock_tenant_id, mock_now, mock_storage, file_service: FileService, db_session: Session ): # Setup mock_tenant_id.return_value = "tenant_id" - mock_now.return_value = "2024-01-01" + mock_now.return_value = datetime(2024, 1, 1, tzinfo=UTC) mock_get_url.return_value = "http://signed-url" user = MagicMock(spec=Account) @@ -81,8 +108,9 @@ class TestFileService: assert result.source_url == "http://signed-url" mock_storage.save.assert_called_once() - mock_db_session.add.assert_called_once_with(result) - mock_db_session.commit.assert_called_once() + persisted = db_session.get(UploadFile, result.id) + assert persisted is not None + assert persisted.hash == result.hash def test_upload_file_uses_explicit_resource_tenant(self, file_service: FileService): user = MagicMock(spec=Account) @@ -109,7 +137,7 @@ class TestFileService: with pytest.raises(ValueError, match="Filename contains invalid characters"): file_service.upload_file(filename="invalid/file.txt", content=b"", mimetype="text/plain", user=MagicMock()) - def test_upload_file_long_filename(self, file_service: FileService, mock_db_session): + def test_upload_file_long_filename(self, file_service: FileService, db_session: Session): # Setup long_name = "a" * 210 + ".txt" user = MagicMock(spec=Account) @@ -124,6 +152,7 @@ class TestFileService: result = file_service.upload_file(filename=long_name, content=b"test", mimetype="text/plain", user=user) assert len(result.name) <= 205 # 200 + . + extension assert result.name.endswith(".txt") + assert db_session.get(UploadFile, result.id) is not None def test_upload_file_blocked_extension(self, file_service): with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe"): @@ -145,7 +174,7 @@ class TestFileService: with pytest.raises(FileTooLargeError): file_service.upload_file(filename="test.jpg", content=content, mimetype="image/jpeg", user=MagicMock()) - def test_upload_file_end_user(self, file_service: FileService, mock_db_session): + def test_upload_file_end_user(self, file_service: FileService, db_session: Session): user = MagicMock(spec=EndUser) user.id = "end_user_id" @@ -157,6 +186,7 @@ class TestFileService: mock_tenant.return_value = "tenant" result = file_service.upload_file(filename="test.txt", content=b"test", mimetype="text/plain", user=user) assert result.created_by_role == CreatorUserRole.END_USER + assert db_session.get(UploadFile, result.id) is not None def test_is_file_size_within_limit(self): with ( @@ -181,12 +211,8 @@ class TestFileService: assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False - def test_get_file_base64_success(self, file_service: FileService, mock_db_session): - # Setup - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.key = "test_key" - mock_db_session.scalar.return_value = upload_file + def test_get_file_base64_success(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session, key="test_key") with patch("services.file_service.storage") as mock_storage: mock_storage.load_once.return_value = b"test content" @@ -198,16 +224,17 @@ class TestFileService: assert result == base64.b64encode(b"test content").decode() mock_storage.load_once.assert_called_once_with("test_key") - def test_get_file_base64_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None + def test_get_file_base64_not_found(self, file_service: FileService): with pytest.raises(NotFound, match="File not found"): file_service.get_file_base64("non_existent") - def test_get_file_presigned_url_success(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.key = "upload_files/tenant_id/icon.png" - upload_file.mime_type = "image/png" - mock_db_session.scalar.return_value = upload_file + def test_get_file_presigned_url_success(self, file_service: FileService, db_session: Session): + self._persist_upload_file( + db_session, + extension="png", + mime_type="image/png", + key="upload_files/tenant_id/icon.png", + ) with ( patch.object(dify_config, "FILES_ACCESS_TIMEOUT", 300), @@ -224,13 +251,11 @@ class TestFileService: content_type="image/png", ) - def test_get_file_presigned_url_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None - + def test_get_file_presigned_url_not_found(self, file_service: FileService): with pytest.raises(NotFound, match="File not found"): file_service.get_file_presigned_url(file_id="file_id", tenant_id="tenant_id") - def test_upload_text_success(self, file_service: FileService, mock_db_session): + def test_upload_text_success(self, file_service: FileService, db_session: Session): # Setup text = "sample text" text_name = "test.txt" @@ -249,21 +274,17 @@ class TestFileService: assert result.used is True assert result.extension == "txt" mock_storage.save.assert_called_once() - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() + assert db_session.get(UploadFile, result.id) is not None - def test_upload_text_long_name(self, file_service: FileService, mock_db_session): + def test_upload_text_long_name(self, file_service: FileService, db_session: Session): long_name = "a" * 210 with patch("services.file_service.storage"): result = file_service.upload_text("text", long_name, "user", "tenant") assert len(result.name) == 200 + assert db_session.get(UploadFile, result.id) is not None - def test_get_file_preview_success(self, file_service: FileService, mock_db_session): - # Setup - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.extension = "pdf" - mock_db_session.scalar.return_value = upload_file + def test_get_file_preview_success(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session, extension="pdf", mime_type="application/pdf") with patch("services.file_service.ExtractProcessor.load_from_upload_file") as mock_extract: mock_extract.return_value = "Extracted text content" @@ -274,27 +295,17 @@ class TestFileService: # Assert assert result == "Extracted text content" - def test_get_file_preview_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None + def test_get_file_preview_not_found(self, file_service: FileService): with pytest.raises(NotFound, match="File not found"): file_service.get_file_preview("non_existent", "tenant_id") - def test_get_file_preview_unsupported_type(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.extension = "exe" - mock_db_session.scalar.return_value = upload_file + def test_get_file_preview_unsupported_type(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session, extension="exe", mime_type="application/octet-stream") with pytest.raises(UnsupportedFileTypeError): file_service.get_file_preview("file_id", "tenant_id") - def test_get_image_preview_success(self, file_service: FileService, mock_db_session): - # Setup - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.extension = "jpg" - upload_file.mime_type = "image/jpeg" - upload_file.key = "key" - mock_db_session.scalar.return_value = upload_file + def test_get_image_preview_success(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session, extension="jpg", mime_type="image/jpeg") with ( patch("services.file_service.file_helpers.verify_image_signature") as mock_verify, @@ -316,28 +327,21 @@ class TestFileService: with pytest.raises(NotFound, match="File not found or signature is invalid"): file_service.get_image_preview("file_id", "ts", "nonce", "sign") - def test_get_image_preview_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None + def test_get_image_preview_not_found(self, file_service: FileService): with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify: mock_verify.return_value = True with pytest.raises(NotFound, match="File not found or signature is invalid"): file_service.get_image_preview("file_id", "ts", "nonce", "sign") - def test_get_image_preview_unsupported_type(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.extension = "txt" - mock_db_session.scalar.return_value = upload_file + def test_get_image_preview_unsupported_type(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session) with patch("services.file_service.file_helpers.verify_image_signature") as mock_verify: mock_verify.return_value = True with pytest.raises(UnsupportedFileTypeError): file_service.get_image_preview("file_id", "ts", "nonce", "sign") - def test_get_file_generator_by_file_id_success(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.key = "key" - mock_db_session.scalar.return_value = upload_file + def test_get_file_generator_by_file_id_success(self, file_service: FileService, db_session: Session): + upload_file = self._persist_upload_file(db_session) with ( patch("services.file_service.file_helpers.verify_file_signature") as mock_verify, @@ -348,7 +352,8 @@ class TestFileService: gen, file = file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign") assert list(gen) == [b"chunk"] - assert file == upload_file + assert file.id == upload_file.id + assert file.key == upload_file.key def test_get_file_generator_by_file_id_invalid_sig(self, file_service): with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify: @@ -356,20 +361,14 @@ class TestFileService: with pytest.raises(NotFound, match="File not found or signature is invalid"): file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign") - def test_get_file_generator_by_file_id_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None + def test_get_file_generator_by_file_id_not_found(self, file_service: FileService): with patch("services.file_service.file_helpers.verify_file_signature") as mock_verify: mock_verify.return_value = True with pytest.raises(NotFound, match="File not found or signature is invalid"): file_service.get_file_generator_by_file_id("file_id", "ts", "nonce", "sign") - def test_get_public_image_preview_success(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.extension = "png" - upload_file.mime_type = "image/png" - upload_file.key = "key" - mock_db_session.scalar.return_value = upload_file + def test_get_public_image_preview_success(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session, extension="png", mime_type="image/png") with patch("services.file_service.storage") as mock_storage: mock_storage.load.return_value = b"image content" @@ -377,66 +376,56 @@ class TestFileService: assert gen == b"image content" assert mime == "image/png" - def test_get_public_image_preview_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None + def test_get_public_image_preview_not_found(self, file_service: FileService): with pytest.raises(NotFound, match="File not found or signature is invalid"): file_service.get_public_image_preview("file_id") - def test_get_public_image_preview_unsupported_type(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.extension = "txt" - mock_db_session.scalar.return_value = upload_file + def test_get_public_image_preview_unsupported_type(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session) with pytest.raises(UnsupportedFileTypeError): file_service.get_public_image_preview("file_id") - def test_get_file_content_success(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.key = "key" - mock_db_session.scalar.return_value = upload_file + def test_get_file_content_success(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session) with patch("services.file_service.storage") as mock_storage: mock_storage.load.return_value = b"hello world" result = file_service.get_file_content("file_id") assert result == "hello world" - def test_get_file_content_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None + def test_get_file_content_not_found(self, file_service: FileService): with pytest.raises(NotFound, match="File not found"): file_service.get_file_content("file_id") - def test_delete_file_success(self, file_service: FileService, mock_db_session): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "file_id" - upload_file.key = "key" - # For session.scalar(select(...)) - mock_db_session.scalar.return_value = upload_file + def test_delete_file_success(self, file_service: FileService, db_session: Session): + self._persist_upload_file(db_session) with patch("services.file_service.storage") as mock_storage: file_service.delete_file("file_id") mock_storage.delete.assert_called_once_with("key") - mock_db_session.delete.assert_called_once_with(upload_file) + db_session.expire_all() + assert db_session.get(UploadFile, "file_id") is None - def test_delete_file_not_found(self, file_service: FileService, mock_db_session): - mock_db_session.scalar.return_value = None + def test_delete_file_not_found(self, file_service: FileService): file_service.delete_file("file_id") # Should return without doing anything - def test_get_upload_files_by_ids_empty(self): - session = MagicMock() - result = FileService.get_upload_files_by_ids("tenant_id", [], session=session) + def test_get_upload_files_by_ids_empty(self, db_session: Session): + result = FileService.get_upload_files_by_ids("tenant_id", [], session=db_session) assert result == {} - def test_get_upload_files_by_ids(self): - upload_file = MagicMock(spec=UploadFile) - upload_file.id = "550e8400-e29b-41d4-a716-446655440000" - upload_file.tenant_id = "tenant_id" - session = MagicMock() - session.scalars().all.return_value = [upload_file] + def test_get_upload_files_by_ids(self, db_session: Session): + upload_file = self._persist_upload_file(db_session, file_id="550e8400-e29b-41d4-a716-446655440000") + self._persist_upload_file( + db_session, + file_id="550e8400-e29b-41d4-a716-446655440001", + tenant_id="other-tenant", + ) result = FileService.get_upload_files_by_ids( - "tenant_id", ["550e8400-e29b-41d4-a716-446655440000"], session=session + "tenant_id", + ["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001"], + session=db_session, ) assert result["550e8400-e29b-41d4-a716-446655440000"] == upload_file @@ -453,10 +442,8 @@ class TestFileService: used.add("a (1).txt") assert FileService._dedupe_zip_entry_name("a.txt", used) == "a (2).txt" - def test_build_upload_files_zip_tempfile(self): - upload_file = MagicMock(spec=UploadFile) - upload_file.name = "test.txt" - upload_file.key = "key" + def test_build_upload_files_zip_tempfile(self, db_session: Session): + upload_file = self._persist_upload_file(db_session) with ( patch("services.file_service.storage") as mock_storage, From 81fb57639e94e587fbda5e96f4dd816384e80530 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:19:17 +0900 Subject: [PATCH 070/531] test: use SQLite sessions in controllers service api (#39098) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- .../service_api/app/test_message.py | 49 +++++++++++------- .../test_rag_pipeline_workflow.py | 50 +++++++++++++------ 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_message.py b/api/tests/unit_tests/controllers/service_api/app/test_message.py index 40186036ed7..14bd2672d60 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_message.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_message.py @@ -15,12 +15,15 @@ Focus on: """ import uuid +from collections.abc import Iterator from inspect import unwrap from types import SimpleNamespace from unittest.mock import Mock, patch import pytest from flask import Flask +from sqlalchemy import Engine +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from controllers.service_api.app.error import NotChatAppError @@ -44,6 +47,14 @@ from services.errors.message import ( from services.message_service import MessageService +@pytest.fixture +def orm_session(sqlite_engine: Engine) -> Iterator[Session]: + """Provide a real caller-owned session for MessageService interface tests.""" + + with Session(sqlite_engine, expire_on_commit=False) as session: + yield session + + class TestMessageListQuery: """Test suite for MessageListQuery Pydantic model.""" @@ -253,7 +264,7 @@ class TestMessageService: assert callable(MessageService.get_suggested_questions_after_answer) @patch.object(MessageService, "pagination_by_first_id") - def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination): + def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination, orm_session: Session): """Test pagination_by_first_id returns expected format.""" mock_result = Mock() mock_result.data = [] @@ -267,7 +278,7 @@ class TestMessageService: conversation_id=str(uuid.uuid4()), first_id=None, limit=20, - session=Mock(), + session=orm_session, ) assert hasattr(result, "data") @@ -275,7 +286,7 @@ class TestMessageService: assert hasattr(result, "has_more") @patch.object(MessageService, "pagination_by_first_id") - def test_pagination_raises_conversation_not_exists_error(self, mock_pagination): + def test_pagination_raises_conversation_not_exists_error(self, mock_pagination, orm_session: Session): """Test pagination raises ConversationNotExistsError.""" import services.errors.conversation @@ -288,11 +299,11 @@ class TestMessageService: conversation_id="invalid_id", first_id=None, limit=20, - session=Mock(), + session=orm_session, ) @patch.object(MessageService, "pagination_by_first_id") - def test_pagination_raises_first_message_not_exists_error(self, mock_pagination): + def test_pagination_raises_first_message_not_exists_error(self, mock_pagination, orm_session: Session): """Test pagination raises FirstMessageNotExistsError.""" mock_pagination.side_effect = FirstMessageNotExistsError() @@ -303,11 +314,11 @@ class TestMessageService: conversation_id=str(uuid.uuid4()), first_id="invalid_first_id", limit=20, - session=Mock(), + session=orm_session, ) @patch.object(MessageService, "create_feedback") - def test_create_feedback_with_rating_and_content(self, mock_create_feedback): + def test_create_feedback_with_rating_and_content(self, mock_create_feedback, orm_session: Session): """Test create_feedback with rating and content.""" mock_create_feedback.return_value = None @@ -317,13 +328,13 @@ class TestMessageService: user=Mock(spec=EndUser), rating=FeedbackRating.LIKE, content="Great response!", - session=Mock(), + session=orm_session, ) mock_create_feedback.assert_called_once() @patch.object(MessageService, "create_feedback") - def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback): + def test_create_feedback_raises_message_not_exists_error(self, mock_create_feedback, orm_session: Session): """Test create_feedback raises MessageNotExistsError.""" mock_create_feedback.side_effect = MessageNotExistsError() @@ -334,11 +345,11 @@ class TestMessageService: user=Mock(spec=EndUser), rating=FeedbackRating.LIKE, content=None, - session=Mock(), + session=orm_session, ) @patch.object(MessageService, "get_all_messages_feedbacks") - def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks): + def test_get_all_messages_feedbacks_returns_list(self, mock_get_feedbacks, orm_session: Session): """Test get_all_messages_feedbacks returns list of feedbacks.""" mock_feedbacks = [ {"message_id": str(uuid.uuid4()), "rating": "like"}, @@ -346,13 +357,15 @@ class TestMessageService: ] mock_get_feedbacks.return_value = mock_feedbacks - result = MessageService.get_all_messages_feedbacks(app_model=Mock(spec=App), page=1, limit=20, session=Mock()) + result = MessageService.get_all_messages_feedbacks( + app_model=Mock(spec=App), page=1, limit=20, session=orm_session + ) assert len(result) == 2 assert result[0]["rating"] == "like" @patch.object(MessageService, "get_suggested_questions_after_answer") - def test_get_suggested_questions_returns_questions_list(self, mock_get_questions): + def test_get_suggested_questions_returns_questions_list(self, mock_get_questions, orm_session: Session): """Test get_suggested_questions_after_answer returns list of questions.""" mock_questions = ["What about this aspect?", "Can you elaborate on that?", "How does this relate to...?"] mock_get_questions.return_value = mock_questions @@ -362,14 +375,14 @@ class TestMessageService: user=Mock(spec=EndUser), message_id=str(uuid.uuid4()), invoke_from=Mock(), - session=Mock(), + session=orm_session, ) assert len(result) == 3 assert isinstance(result[0], str) @patch.object(MessageService, "get_suggested_questions_after_answer") - def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions): + def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions, orm_session: Session): """Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError.""" mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError() @@ -379,11 +392,11 @@ class TestMessageService: user=Mock(spec=EndUser), message_id=str(uuid.uuid4()), invoke_from=Mock(), - session=Mock(), + session=orm_session, ) @patch.object(MessageService, "get_suggested_questions_after_answer") - def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions): + def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session): """Test get_suggested_questions_after_answer raises MessageNotExistsError.""" mock_get_questions.side_effect = MessageNotExistsError() @@ -393,7 +406,7 @@ class TestMessageService: user=Mock(spec=EndUser), message_id="invalid_message_id", invoke_from=Mock(), - session=Mock(), + session=orm_session, ) diff --git a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py index 406037e268d..fe7d035df5f 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py @@ -24,6 +24,7 @@ from unittest.mock import Mock, patch import pytest from flask import Flask +from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage from werkzeug.exceptions import Forbidden, NotFound @@ -38,6 +39,7 @@ from controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow import ( ) from core.app.entities.app_invoke_entities import InvokeFrom from models.account import Account +from models.dataset import Dataset from services.errors.file import FileTooLargeError, UnsupportedFileTypeError from services.rag_pipeline.entity.pipeline_service_api_entities import ( DatasourceNodeRunApiEntity, @@ -46,6 +48,20 @@ from services.rag_pipeline.entity.pipeline_service_api_entities import ( from services.rag_pipeline.rag_pipeline import RagPipelineService +def _persist_dataset(session: Session, *, tenant_id: str, dataset_id: str) -> Dataset: + dataset = Dataset( + id=dataset_id, + tenant_id=tenant_id, + name="Pipeline dataset", + created_by="account-1", + data_source_type=None, + indexing_technique=None, + ) + session.add(dataset) + session.commit() + return dataset + + class TestDatasourceNodeRunPayload: """Test suite for DatasourceNodeRunPayload Pydantic model.""" @@ -550,13 +566,15 @@ class TestPipelineRunApiPost: ) @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.RagPipelineService") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") - def test_post_success_streaming(self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app): + @pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True) + def test_post_success_streaming( + self, mock_ns, mock_svc_cls, mock_current_user, mock_gen_svc, mock_helper, app, sqlite_session: Session + ): """Test successful pipeline run with streaming response.""" tenant_id = str(uuid.uuid4()) dataset_id = str(uuid.uuid4()) - session = Mock() - session.scalar.return_value = Mock() + _persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id) mock_ns.payload = { "inputs": {"key": "val"}, @@ -577,33 +595,33 @@ class TestPipelineRunApiPost: with app.test_request_context("/datasets/test/pipeline/run", method="POST"): api = PipelineRunApi() - response = api.post.__wrapped__(api, session, tenant_id=tenant_id, dataset_id=dataset_id) + response = api.post.__wrapped__(api, sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id) assert response == {"result": "ok"} - mock_svc_cls.assert_called_once_with(session) + mock_svc_cls.assert_called_once_with(sqlite_session) mock_gen_svc.generate.assert_called_once() - def test_post_not_found(self, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True) + def test_post_not_found(self, app: Flask, sqlite_session: Session): """Test NotFound when dataset check fails.""" - session = Mock() - session.scalar.return_value = None - with app.test_request_context("/datasets/test/pipeline/run", method="POST"): api = PipelineRunApi() with pytest.raises(NotFound): api.post.__wrapped__( api, - session, + sqlite_session, tenant_id=str(uuid.uuid4()), dataset_id=str(uuid.uuid4()), ) @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.current_user", new="not_account") @patch("controllers.service_api.dataset.rag_pipeline.rag_pipeline_workflow.service_api_ns") - def test_post_forbidden_non_account_user(self, mock_ns, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True) + def test_post_forbidden_non_account_user(self, mock_ns, app: Flask, sqlite_session: Session): """Test Forbidden when current_user is not an Account.""" - session = Mock() - session.scalar.return_value = Mock() + tenant_id = str(uuid.uuid4()) + dataset_id = str(uuid.uuid4()) + _persist_dataset(sqlite_session, tenant_id=tenant_id, dataset_id=dataset_id) mock_ns.payload = { "inputs": {}, "datasource_type": "online_document", @@ -618,9 +636,9 @@ class TestPipelineRunApiPost: with pytest.raises(Forbidden): api.post.__wrapped__( api, - session, - tenant_id=str(uuid.uuid4()), - dataset_id=str(uuid.uuid4()), + sqlite_session, + tenant_id=tenant_id, + dataset_id=dataset_id, ) From da0979b373bec0966a9cff333ce119c669b15c38 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:20:27 +0900 Subject: [PATCH 071/531] test: use SQLite sessions in services core (#39090) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- .../test_workflow_run_service_pause.py | 193 ++++-------------- 1 file changed, 36 insertions(+), 157 deletions(-) diff --git a/api/tests/unit_tests/services/test_workflow_run_service_pause.py b/api/tests/unit_tests/services/test_workflow_run_service_pause.py index 239cc83518e..5b24cfd8a66 100644 --- a/api/tests/unit_tests/services/test_workflow_run_service_pause.py +++ b/api/tests/unit_tests/services/test_workflow_run_service_pause.py @@ -1,178 +1,57 @@ -"""Comprehensive unit tests for WorkflowRunService class. +"""Tests for the session lifecycle owned by ``WorkflowRunService``.""" -This test suite covers all pause state management operations including: -- Retrieving pause state for workflow runs -- Saving pause state with file uploads -- Marking paused workflows as resumed -- Error handling and edge cases -- Database transaction management -- Repository-based approach testing -""" - -from datetime import datetime -from unittest.mock import MagicMock, create_autospec, patch +from unittest.mock import create_autospec, patch import pytest -from sqlalchemy import Engine +from sqlalchemy import Engine, text from sqlalchemy.orm import Session, sessionmaker -from graphon.enums import WorkflowExecutionStatus -from models.workflow import WorkflowPause from repositories.api_workflow_run_repository import APIWorkflowRunRepository -from repositories.sqlalchemy_api_workflow_run_repository import _PrivateWorkflowPauseEntity -from services.workflow_run_service import ( - WorkflowRunService, -) +from services.workflow_run_service import WorkflowRunService -class TestDataFactory: - """Factory class for creating test data objects.""" - - @staticmethod - def create_workflow_run_mock( - id: str = "workflow-run-123", - tenant_id: str = "tenant-456", - app_id: str = "app-789", - workflow_id: str = "workflow-101", - status: str | WorkflowExecutionStatus = "paused", - **kwargs, - ) -> MagicMock: - """Create a mock WorkflowRun object.""" - mock_run = MagicMock() - mock_run.id = id - mock_run.tenant_id = tenant_id - mock_run.app_id = app_id - mock_run.workflow_id = workflow_id - mock_run.status = status - - for key, value in kwargs.items(): - setattr(mock_run, key, value) - - return mock_run - - @staticmethod - def create_workflow_pause_mock( - id: str = "pause-123", - tenant_id: str = "tenant-456", - app_id: str = "app-789", - workflow_id: str = "workflow-101", - workflow_execution_id: str = "workflow-execution-123", - state_file_id: str = "file-456", - resumed_at: datetime | None = None, - **kwargs, - ) -> MagicMock: - """Create a mock WorkflowPauseModel object.""" - mock_pause = MagicMock(spec=WorkflowPause) - mock_pause.id = id - mock_pause.tenant_id = tenant_id - mock_pause.app_id = app_id - mock_pause.workflow_id = workflow_id - mock_pause.workflow_execution_id = workflow_execution_id - mock_pause.state_file_id = state_file_id - mock_pause.resumed_at = resumed_at - - for key, value in kwargs.items(): - setattr(mock_pause, key, value) - - return mock_pause - - @staticmethod - def create_pause_entity_mock( - pause_model: MagicMock | None = None, - ) -> _PrivateWorkflowPauseEntity: - """Create a mock _PrivateWorkflowPauseEntity object.""" - if pause_model is None: - pause_model = TestDataFactory.create_workflow_pause_mock() - - return _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=[], human_input_form=[]) +@pytest.fixture +def sqlite_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]: + """Return a real factory whose sessions are bound to the isolated SQLite engine.""" + return sessionmaker(bind=sqlite_engine, expire_on_commit=False) -class TestWorkflowRunService: - """Comprehensive unit tests for WorkflowRunService class.""" +@pytest.fixture +def workflow_run_repository(): + """Keep the repository boundary mocked while exercising real session construction.""" + return create_autospec(APIWorkflowRunRepository) - @pytest.fixture - def mock_session_factory(self): - """Create a mock session factory with proper session management.""" - mock_session = create_autospec(Session) - # Create a mock context manager for the session - mock_session_cm = MagicMock() - mock_session_cm.__enter__ = MagicMock(return_value=mock_session) - mock_session_cm.__exit__ = MagicMock(return_value=None) +def test_init_with_session_factory( + sqlite_session_factory: sessionmaker[Session], workflow_run_repository: APIWorkflowRunRepository +) -> None: + with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory: + repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository - # Create a mock context manager for the transaction - mock_transaction_cm = MagicMock() - mock_transaction_cm.__enter__ = MagicMock(return_value=mock_session) - mock_transaction_cm.__exit__ = MagicMock(return_value=None) + service = WorkflowRunService(sqlite_session_factory) - mock_session.begin = MagicMock(return_value=mock_transaction_cm) + assert service._session_factory is sqlite_session_factory + repository_factory.create_api_workflow_run_repository.assert_called_once_with(sqlite_session_factory) + with service._session_factory() as session: + assert session.scalar(text("SELECT 1")) == 1 - # Create mock factory that returns the context manager - mock_factory = MagicMock(spec=sessionmaker) - mock_factory.return_value = mock_session_cm - return mock_factory, mock_session +def test_init_with_engine_creates_bound_session_factory( + sqlite_engine: Engine, workflow_run_repository: APIWorkflowRunRepository +) -> None: + with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as repository_factory: + repository_factory.create_api_workflow_run_repository.return_value = workflow_run_repository - @pytest.fixture - def mock_workflow_run_repository(self): - """Create a mock APIWorkflowRunRepository.""" - mock_repo = create_autospec(APIWorkflowRunRepository) - return mock_repo + service = WorkflowRunService(sqlite_engine) - @pytest.fixture - def workflow_run_service(self, mock_session_factory, mock_workflow_run_repository): - """Create WorkflowRunService instance with mocked dependencies.""" - session_factory, _ = mock_session_factory + assert service._session_factory.kw["bind"] is sqlite_engine + assert service._session_factory.kw["expire_on_commit"] is False + repository_factory.create_api_workflow_run_repository.assert_called_once_with(service._session_factory) + with service._session_factory() as session: + assert session.scalar(text("SELECT 1")) == 1 - with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory: - mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository - service = WorkflowRunService(session_factory) - return service - @pytest.fixture - def workflow_run_service_with_engine(self, mock_session_factory, mock_workflow_run_repository): - """Create WorkflowRunService instance with Engine input.""" - mock_engine = create_autospec(Engine) - session_factory, _ = mock_session_factory +def test_init_with_default_repository_dependencies(sqlite_session_factory: sessionmaker[Session]) -> None: + service = WorkflowRunService(sqlite_session_factory) - with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory: - mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository - service = WorkflowRunService(mock_engine) - return service - - # ==================== Initialization Tests ==================== - - def test_init_with_session_factory(self, mock_session_factory, mock_workflow_run_repository): - """Test WorkflowRunService initialization with session_factory.""" - session_factory, _ = mock_session_factory - - with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory: - mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository - service = WorkflowRunService(session_factory) - - assert service._session_factory == session_factory - mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory) - - def test_init_with_engine(self, mock_session_factory, mock_workflow_run_repository): - """Test WorkflowRunService initialization with Engine (should convert to sessionmaker).""" - mock_engine = create_autospec(Engine) - session_factory, _ = mock_session_factory - - with patch("services.workflow_run_service.DifyAPIRepositoryFactory", autospec=True) as mock_factory: - mock_factory.create_api_workflow_run_repository.return_value = mock_workflow_run_repository - with patch( - "services.workflow_run_service.sessionmaker", return_value=session_factory, autospec=True - ) as mock_sessionmaker: - service = WorkflowRunService(mock_engine) - - mock_sessionmaker.assert_called_once_with(bind=mock_engine, expire_on_commit=False) - assert service._session_factory == session_factory - mock_factory.create_api_workflow_run_repository.assert_called_once_with(session_factory) - - def test_init_with_default_dependencies(self, mock_session_factory): - """Test WorkflowRunService initialization with default dependencies.""" - session_factory, _ = mock_session_factory - - service = WorkflowRunService(session_factory) - - assert service._session_factory == session_factory + assert service._session_factory is sqlite_session_factory From 003e0f96149aef43014a63f16b3929388725298a Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:26:43 +0900 Subject: [PATCH 072/531] test: move message cleanup coverage to unit tests (#38932) --- .../services/test_messages_clean_service.py | 59 +------------------ 1 file changed, 1 insertion(+), 58 deletions(-) diff --git a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py index 1a1efe03371..a003c2dbb81 100644 --- a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py +++ b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py @@ -4,7 +4,7 @@ import datetime import json import uuid from decimal import Decimal -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from faker import Faker @@ -1172,65 +1172,8 @@ class TestMessagesCleanServiceIntegration: # Verify all messages were deleted assert db_session_with_containers.query(Message).where(Message.id.in_(msg_ids)).count() == 0 - def test_from_time_range_validation(self): - """Test that from_time_range raises ValueError for invalid inputs.""" - policy = MagicMock(spec=BillingDisabledPolicy) - now = datetime.datetime.now() - - with pytest.raises(ValueError, match="start_from .* must be less than end_before"): - MessagesCleanService.from_time_range(policy, now, now) - - with pytest.raises(ValueError, match="batch_size .* must be greater than 0"): - MessagesCleanService.from_time_range(policy, now - datetime.timedelta(days=1), now, batch_size=0) - - def test_from_time_range_success(self): - """Test that from_time_range creates a service with correct parameters.""" - policy = MagicMock(spec=BillingDisabledPolicy) - start = datetime.datetime(2024, 1, 1) - end = datetime.datetime(2024, 2, 1) - - service = MessagesCleanService.from_time_range(policy, start, end) - assert service._start_from == start - assert service._end_before == end - - def test_from_days_validation(self): - """Test that from_days raises ValueError for invalid inputs.""" - policy = MagicMock(spec=BillingDisabledPolicy) - - with pytest.raises(ValueError, match="days .* must be greater than or equal to 0"): - MessagesCleanService.from_days(policy, days=-1) - - with pytest.raises(ValueError, match="batch_size .* must be greater than 0"): - MessagesCleanService.from_days(policy, days=30, batch_size=0) - - def test_from_days_success(self): - """Test that from_days creates a service with correct parameters.""" - policy = MagicMock(spec=BillingDisabledPolicy) - - with patch("services.retention.conversation.messages_clean_service.naive_utc_now") as mock_now: - fixed_now = datetime.datetime(2024, 6, 1) - mock_now.return_value = fixed_now - - service = MessagesCleanService.from_days(policy, days=10) - assert service._start_from is None - assert service._end_before == fixed_now - datetime.timedelta(days=10) - def test_batch_delete_message_relations_empty(self, db_session_with_containers: Session): """Test that batch_delete_message_relations with empty list does nothing.""" # Get execute call count before MessagesCleanService._batch_delete_message_relations(db_session_with_containers, []) # No exception means success — empty list is a no-op - - def test_run_calls_clean_messages(self): - """Test that run() delegates to _clean_messages_by_time_range.""" - policy = MagicMock(spec=BillingDisabledPolicy) - service = MessagesCleanService( - policy=policy, - end_before=datetime.datetime.now(), - batch_size=10, - ) - with patch.object(service, "_clean_messages_by_time_range") as mock_clean: - mock_clean.return_value = {"total_deleted": 5} - result = service.run() - assert result == {"total_deleted": 5} - mock_clean.assert_called_once() From 2d9b2d50f3c9d141980eb4ca38205f48d5a57549 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:27:22 +0900 Subject: [PATCH 073/531] test: use sqlite3 session in test_wraps (#38770) --- .../controllers/service_api/test_wraps.py | 291 +++++++++++------- 1 file changed, 179 insertions(+), 112 deletions(-) diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index 5857e5c639a..f5b8c15d6d1 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -3,10 +3,13 @@ Unit tests for Service API wraps (authentication decorators) """ import uuid +from types import SimpleNamespace from unittest.mock import MagicMock, Mock, patch import pytest from flask import Flask +from sqlalchemy import select +from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, NotFound, Unauthorized from controllers.service_api.wraps import ( @@ -21,12 +24,11 @@ from controllers.service_api.wraps import ( validate_dataset_token, ) from enums.cloud_plan import CloudPlan -from models.account import TenantStatus -from models.model import ApiToken -from tests.unit_tests.conftest import ( - setup_mock_dataset_owner_execute_result, - setup_mock_tenant_owner_execute_result, -) +from models import Account, Tenant, TenantAccountJoin +from models.account import TenantAccountRole +from models.dataset import Dataset, RateLimitLog +from models.enums import ApiTokenType +from models.model import ApiToken, App, AppMode, IconType def _configure_current_app_mock(mock_current_app): @@ -34,6 +36,51 @@ def _configure_current_app_mock(mock_current_app): mock_current_app._get_current_object = Mock(return_value=Mock()) +def _session_proxy(session: Session) -> MagicMock: + """Emulate Flask-SQLAlchemy's callable scoped-session proxy around a test session.""" + proxy = MagicMock(wraps=session) + proxy.return_value = session + return proxy + + +def _api_token(*, tenant_id: str, app_id: str | None = None, token_type: ApiTokenType) -> ApiToken: + return ApiToken( + id=str(uuid.uuid4()), + tenant_id=tenant_id, + app_id=app_id, + type=token_type, + token="test_token", + ) + + +def _persist_workspace(session: Session) -> tuple[Tenant, Account, TenantAccountJoin]: + tenant = Tenant(name="Workspace") + account = Account(name="Owner", email=f"owner-{uuid.uuid4()}@example.com") + membership = TenantAccountJoin( + tenant_id=tenant.id, + account_id=account.id, + current=True, + role=TenantAccountRole.OWNER, + ) + session.add_all([tenant, account, membership]) + session.commit() + return tenant, account, membership + + +def _app_model(*, tenant_id: str, enable_api: bool = True) -> App: + return App( + id=str(uuid.uuid4()), + tenant_id=tenant_id, + name="Service API App", + mode=AppMode.CHAT, + icon_type=IconType.EMOJI, + icon="chat", + icon_background="#FFFFFF", + enable_site=False, + enable_api=enable_api, + ) + + class TestValidateAndGetApiToken: """Test suite for validate_and_get_api_token function""" @@ -70,21 +117,24 @@ class TestValidateAndGetApiToken: def test_valid_token_returns_api_token(self, mock_fetch_token, mock_cache_cls, mock_record_usage, app: Flask): """Test that valid token returns the ApiToken object.""" # Arrange - mock_api_token = Mock(spec=ApiToken) - mock_api_token.token = "valid_token_123" - mock_api_token.type = "app" + api_token = _api_token( + tenant_id=str(uuid.uuid4()), + app_id=str(uuid.uuid4()), + token_type=ApiTokenType.APP, + ) + api_token.token = "valid_token_123" mock_cache_instance = Mock() mock_cache_instance.get.return_value = None # Cache miss mock_cache_cls.get = mock_cache_instance.get - mock_fetch_token.return_value = mock_api_token + mock_fetch_token.return_value = api_token # Act with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer valid_token_123"}): result = validate_and_get_api_token("app") # Assert - assert result == mock_api_token + assert result == api_token @patch("controllers.service_api.wraps.record_token_usage") @patch("controllers.service_api.wraps.ApiTokenCache") @@ -117,116 +167,124 @@ class TestValidateAppToken: return app @patch("controllers.service_api.wraps.user_logged_in") - @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.current_app") + @pytest.mark.parametrize( + "sqlite_session", + [(App, ApiToken, Tenant, Account, TenantAccountJoin)], + indirect=True, + ) def test_valid_app_token_allows_access( - self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app + self, + mock_current_app, + mock_validate_token, + mock_user_logged_in, + app: Flask, + sqlite_session: Session, ): """Test that valid app token allows access to decorated view.""" # Arrange _configure_current_app_mock(mock_current_app) - mock_api_token = Mock() - mock_api_token.app_id = str(uuid.uuid4()) - mock_api_token.tenant_id = str(uuid.uuid4()) - mock_validate_token.return_value = mock_api_token - - mock_app = Mock() - mock_app.id = mock_api_token.app_id - mock_app.status = "normal" - mock_app.enable_api = True - mock_app.tenant_id = mock_api_token.tenant_id - - mock_tenant = Mock() - mock_tenant.status = TenantStatus.NORMAL - mock_tenant.id = mock_api_token.tenant_id - - mock_account = Mock() - mock_account.id = str(uuid.uuid4()) - - # Use side_effect to return app first, then tenant via session.get() - mock_db.session.get.side_effect = [mock_app, mock_tenant] - - # Mock the tenant owner execute result (execute(select(...)).one_or_none()) - setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) + tenant, account, _ = _persist_workspace(sqlite_session) + app_model = _app_model(tenant_id=tenant.id) + api_token = _api_token(tenant_id=tenant.id, app_id=app_model.id, token_type=ApiTokenType.APP) + sqlite_session.add_all([app_model, api_token]) + sqlite_session.commit() + mock_validate_token.return_value = api_token @validate_app_token def protected_view(app_model): return {"success": True, "app_id": app_model.id} # Act - with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}): + with ( + app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}), + patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)), + ): result = protected_view() # Assert assert result["success"] is True - assert result["app_id"] == mock_app.id + assert result["app_id"] == app_model.id + assert account.current_tenant_id == tenant.id - @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_app_not_found_raises_forbidden(self, mock_validate_token, mock_db, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) + def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session): """Test that Forbidden is raised when app no longer exists.""" # Arrange - mock_api_token = Mock() - mock_api_token.app_id = str(uuid.uuid4()) - mock_validate_token.return_value = mock_api_token - - mock_db.session.get.return_value = None + api_token = _api_token( + tenant_id=str(uuid.uuid4()), + app_id=str(uuid.uuid4()), + token_type=ApiTokenType.APP, + ) + mock_validate_token.return_value = api_token @validate_app_token def protected_view(**kwargs): return {"success": True} # Act & Assert - with app.test_request_context("/", method="GET"): + with ( + app.test_request_context("/", method="GET"), + patch("controllers.service_api.wraps.db.session", sqlite_session), + ): with pytest.raises(Forbidden) as exc_info: protected_view() assert "no longer exists" in str(exc_info.value) - @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, mock_db, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) + def test_app_status_abnormal_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session): """Test that Forbidden is raised when app status is abnormal.""" # Arrange - mock_api_token = Mock() - mock_api_token.app_id = str(uuid.uuid4()) - mock_validate_token.return_value = mock_api_token - - mock_app = Mock() - mock_app.status = "abnormal" - mock_db.session.get.return_value = mock_app + app_model = _app_model(tenant_id=str(uuid.uuid4())) + sqlite_session.add(app_model) + sqlite_session.commit() + app_model.status = "abnormal" + mock_validate_token.return_value = _api_token( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + token_type=ApiTokenType.APP, + ) @validate_app_token def protected_view(**kwargs): return {"success": True} # Act & Assert - with app.test_request_context("/", method="GET"): + with ( + app.test_request_context("/", method="GET"), + patch("controllers.service_api.wraps.db.session", sqlite_session), + ): with pytest.raises(Forbidden) as exc_info: protected_view() assert "status is abnormal" in str(exc_info.value) - @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_app_api_disabled_raises_forbidden(self, mock_validate_token, mock_db, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True) + def test_app_api_disabled_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session): """Test that Forbidden is raised when app API is disabled.""" # Arrange - mock_api_token = Mock() - mock_api_token.app_id = str(uuid.uuid4()) - mock_validate_token.return_value = mock_api_token - - mock_app = Mock() - mock_app.status = "normal" - mock_app.enable_api = False - mock_db.session.get.return_value = mock_app + app_model = _app_model(tenant_id=str(uuid.uuid4()), enable_api=False) + sqlite_session.add(app_model) + sqlite_session.commit() + mock_validate_token.return_value = _api_token( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + token_type=ApiTokenType.APP, + ) @validate_app_token def protected_view(**kwargs): return {"success": True} # Act & Assert - with app.test_request_context("/", method="GET"): + with ( + app.test_request_context("/", method="GET"), + patch("controllers.service_api.wraps.db.session", sqlite_session), + ): with pytest.raises(Forbidden) as exc_info: protected_view() assert "API service has been disabled" in str(exc_info.value) @@ -468,26 +526,35 @@ class TestCloudEditionBillingRateLimitCheck: @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit") - @patch("controllers.service_api.wraps.db") - @patch("controllers.service_api.wraps.sessionmaker") + @pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True) def test_rejects_over_rate_limit( - self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask + self, + mock_get_rate_limit, + mock_validate_token, + app: Flask, + sqlite_session: Session, ): """Test that Forbidden is raised when over rate limit.""" # Arrange - mock_validate_token.return_value = Mock(tenant_id="tenant123") + tenant_id = str(uuid.uuid4()) + mock_validate_token.return_value = _api_token( + tenant_id=tenant_id, + token_type=ApiTokenType.DATASET, + ) mock_rate_limit = Mock() mock_rate_limit.enabled = True mock_rate_limit.limit = 10 mock_rate_limit.subscription_plan = "pro" mock_get_rate_limit.return_value = mock_rate_limit - rate_limit_log_session = MagicMock() - session_factory = MagicMock() - session_factory.begin.return_value.__enter__.return_value = rate_limit_log_session - mock_sessionmaker.return_value = session_factory - with patch("controllers.service_api.wraps.redis_client") as mock_redis: + with ( + patch("controllers.service_api.wraps.redis_client") as mock_redis, + patch( + "controllers.service_api.wraps.db", + SimpleNamespace(engine=sqlite_session.get_bind()), + ), + ): mock_redis.zcard.return_value = 15 # Over limit @cloud_edition_billing_rate_limit_check("knowledge", "dataset") @@ -499,9 +566,12 @@ class TestCloudEditionBillingRateLimitCheck: with pytest.raises(Forbidden) as exc_info: knowledge_request() assert "rate limit" in str(exc_info.value) - mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False) - rate_limit_log_session.add.assert_called_once() - mock_db.session.commit.assert_not_called() + + persisted_logs = sqlite_session.scalars(select(RateLimitLog)).all() + assert len(persisted_logs) == 1 + assert persisted_logs[0].tenant_id == tenant_id + assert persisted_logs[0].subscription_plan == "pro" + assert persisted_logs[0].operation == "knowledge" class TestValidateDatasetToken: @@ -515,65 +585,62 @@ class TestValidateDatasetToken: return app @patch("controllers.service_api.wraps.user_logged_in") - @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") @patch("controllers.service_api.wraps.current_app") - def test_valid_dataset_token(self, mock_current_app, mock_validate_token, mock_db, mock_user_logged_in, app: Flask): + @pytest.mark.parametrize( + "sqlite_session", + [(Tenant, Account, TenantAccountJoin)], + indirect=True, + ) + def test_valid_dataset_token( + self, + mock_current_app, + mock_validate_token, + mock_user_logged_in, + app: Flask, + sqlite_session: Session, + ): """Test that valid dataset token allows access.""" # Arrange _configure_current_app_mock(mock_current_app) - tenant_id = str(uuid.uuid4()) - mock_api_token = Mock() - mock_api_token.tenant_id = tenant_id - mock_validate_token.return_value = mock_api_token - - mock_tenant = Mock() - mock_tenant.id = tenant_id - mock_tenant.status = TenantStatus.NORMAL - - mock_ta = Mock() - mock_ta.account_id = str(uuid.uuid4()) - - mock_account = Mock() - mock_account.id = mock_ta.account_id - mock_account.current_tenant = mock_tenant - - # Mock the tenant account join query (execute(select(...)).one_or_none()) - setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta) - - # Mock the account lookup via session.get() - mock_db.session.get.return_value = mock_account + tenant, account, _ = _persist_workspace(sqlite_session) + api_token = _api_token(tenant_id=tenant.id, token_type=ApiTokenType.DATASET) + mock_validate_token.return_value = api_token @validate_dataset_token def protected_view(tenant_id): return {"success": True, "tenant_id": tenant_id} # Act - with app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}): + with ( + app.test_request_context("/", method="GET", headers={"Authorization": "Bearer test_token"}), + patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)), + ): result = protected_view() # Assert assert result["success"] is True - assert result["tenant_id"] == tenant_id + assert result["tenant_id"] == tenant.id + assert account.current_tenant_id == tenant.id - @patch("controllers.service_api.wraps.db") @patch("controllers.service_api.wraps.validate_and_get_api_token") - def test_dataset_not_found_raises_not_found(self, mock_validate_token, mock_db, app: Flask): + @pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True) + def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session): """Test that NotFound is raised when dataset doesn't exist.""" # Arrange - mock_api_token = Mock() - mock_api_token.tenant_id = str(uuid.uuid4()) - mock_validate_token.return_value = mock_api_token - - mock_db.session.scalar.return_value = None + api_token = _api_token(tenant_id=str(uuid.uuid4()), token_type=ApiTokenType.DATASET) + mock_validate_token.return_value = api_token @validate_dataset_token def protected_view(dataset_id=None, **kwargs): return {"success": True} # Act & Assert - with app.test_request_context("/", method="GET"): + with ( + app.test_request_context("/", method="GET"), + patch("controllers.service_api.wraps.db.session", sqlite_session), + ): with pytest.raises(NotFound) as exc_info: protected_view(dataset_id=str(uuid.uuid4())) assert "Dataset not found" in str(exc_info.value) From f44dd343dade4418e1189ba273985b35dbe721e2 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:27:58 +0900 Subject: [PATCH 074/531] test: use sqlite3 session in test_plugin_service (#38727) --- .../services/plugin/test_plugin_service.py | 108 ++++++++++++++---- 1 file changed, 85 insertions(+), 23 deletions(-) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index b33fe27e075..19bb2c0a359 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -7,28 +7,19 @@ import pytest import zstandard from pydantic import TypeAdapter from redis import RedisError +from sqlalchemy.orm import Session +from core.helper.model_provider_cache import ProviderCredentialsCacheType from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity +from models.provider import Provider, ProviderCredential, ProviderType, TenantPreferredModelProvider MODULE = "core.plugin.plugin_service" - - -class _FakeSession: - def __init__(self) -> None: - self.execute = Mock() - self.scalars = Mock(return_value=SimpleNamespace(all=Mock(return_value=[]))) - - def __enter__(self) -> "_FakeSession": - return self - - def __exit__(self, exc_type, exc, traceback) -> None: - return None - - def begin(self) -> "_FakeSession": - return self +TENANT_ID = "11111111-1111-1111-1111-111111111111" +OTHER_TENANT_ID = "22222222-2222-2222-2222-222222222222" +USER_ID = "33333333-3333-3333-3333-333333333333" def _build_provider_entity(provider: str = "openai") -> ProviderEntity: @@ -1166,19 +1157,72 @@ class TestPluginModelProviderCacheInvalidation: assert result is True invalidate_cache.assert_called_once_with("tenant-1") - def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(self) -> None: + @pytest.mark.parametrize( + "sqlite_session", [(Provider, ProviderCredential, TenantPreferredModelProvider)], indirect=True + ) + def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup( + self, sqlite_session: Session + ) -> None: """Successful uninstall with plugin metadata also invalidates the mutated tenant provider cache.""" + plugin_id = "langgenius/openai" + provider_name = f"{plugin_id}/openai" plugin = SimpleNamespace( installation_id="installation-1", - plugin_id="langgenius/openai", + plugin_id=plugin_id, plugin_unique_identifier="langgenius/openai:1.0.0", ) - session = _FakeSession() + credential = ProviderCredential( + tenant_id=TENANT_ID, + provider_name=provider_name, + credential_name="Target credential", + encrypted_config="{}", + user_id=USER_ID, + ) + other_credential = ProviderCredential( + tenant_id=OTHER_TENANT_ID, + provider_name=provider_name, + credential_name="Other credential", + encrypted_config="{}", + user_id=USER_ID, + ) + sqlite_session.add_all([credential, other_credential]) + sqlite_session.flush() + provider = Provider( + tenant_id=TENANT_ID, + provider_name=provider_name, + provider_type=ProviderType.CUSTOM, + credential_id=credential.id, + ) + other_provider = Provider( + tenant_id=OTHER_TENANT_ID, + provider_name=provider_name, + provider_type=ProviderType.CUSTOM, + credential_id=other_credential.id, + ) + preferred_provider = TenantPreferredModelProvider( + tenant_id=TENANT_ID, + provider_name=provider_name, + preferred_provider_type=ProviderType.CUSTOM, + ) + other_preferred_provider = TenantPreferredModelProvider( + tenant_id=OTHER_TENANT_ID, + provider_name=provider_name, + preferred_provider_type=ProviderType.CUSTOM, + ) + sqlite_session.add_all([provider, other_provider, preferred_provider, other_preferred_provider]) + sqlite_session.commit() + credential_id = credential.id + other_credential_id = other_credential.id + provider_id = provider.id + other_provider_id = other_provider.id + preferred_provider_id = preferred_provider.id + other_preferred_provider_id = other_preferred_provider.id + with ( - patch(f"{MODULE}.db", SimpleNamespace(engine=object())), + patch(f"{MODULE}.db", SimpleNamespace(engine=sqlite_session.get_bind())), patch(f"{MODULE}.dify_config") as mock_config, patch(f"{MODULE}.PluginInstaller") as installer_cls, - patch(f"{MODULE}.Session", return_value=session), + patch(f"{MODULE}.ProviderCredentialsCache") as credentials_cache, patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache, ): mock_config.ENTERPRISE_ENABLED = False @@ -1188,8 +1232,26 @@ class TestPluginModelProviderCacheInvalidation: from core.plugin.plugin_service import PluginService - result = PluginService.uninstall("tenant-1", "installation-1") + result = PluginService.uninstall(TENANT_ID, "installation-1") assert result is True - installer.uninstall.assert_called_once_with("tenant-1", "installation-1") - invalidate_cache.assert_called_once_with("tenant-1") + installer.uninstall.assert_called_once_with(TENANT_ID, "installation-1") + invalidate_cache.assert_called_once_with(TENANT_ID) + credentials_cache.assert_called_once_with( + tenant_id=TENANT_ID, + identity_id=provider_id, + cache_type=ProviderCredentialsCacheType.PROVIDER, + ) + credentials_cache.return_value.delete.assert_called_once_with() + + sqlite_session.expunge_all() + assert sqlite_session.get(ProviderCredential, credential_id) is None + persisted_provider = sqlite_session.get(Provider, provider_id) + assert persisted_provider is not None + assert persisted_provider.credential_id is None + assert sqlite_session.get(TenantPreferredModelProvider, preferred_provider_id) is None + assert sqlite_session.get(ProviderCredential, other_credential_id) is not None + persisted_other_provider = sqlite_session.get(Provider, other_provider_id) + assert persisted_other_provider is not None + assert persisted_other_provider.credential_id == other_credential_id + assert sqlite_session.get(TenantPreferredModelProvider, other_preferred_provider_id) is not None From 3c7ad816d91d16c826d3771e338acac70f41ebe2 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:29:02 +0900 Subject: [PATCH 075/531] test: use SQLite sessions in commands (#39080) --- .../test_legacy_model_type_migration.py | 322 +++++++++--------- 1 file changed, 161 insertions(+), 161 deletions(-) diff --git a/api/tests/unit_tests/commands/test_legacy_model_type_migration.py b/api/tests/unit_tests/commands/test_legacy_model_type_migration.py index 9eb73b82516..c5402df700e 100644 --- a/api/tests/unit_tests/commands/test_legacy_model_type_migration.py +++ b/api/tests/unit_tests/commands/test_legacy_model_type_migration.py @@ -6,6 +6,7 @@ import json import os import threading import time +from collections.abc import Iterator from datetime import datetime, timedelta from pathlib import Path from types import SimpleNamespace @@ -15,9 +16,12 @@ import pytest import sqlalchemy as sa from click.testing import CliRunner from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session, SessionTransaction, sessionmaker from graphon.model_runtime.entities.model_entities import ModelType +from models import Dataset, DatasetPermission, DatasetPermissionEnum from models.account import Tenant +from models.base import TypeBase from models.enums import CredentialSourceType from models.provider import ProviderModel from tests.helpers.legacy_model_type_migration import ( @@ -59,6 +63,40 @@ def command_module(): ) +@pytest.fixture +def rbac_session(sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: + """Bind RBAC command reads to persisted SQLite dataset rows.""" + + TypeBase.metadata.create_all( + sqlite_engine, + tables=[Dataset.__table__, DatasetPermission.__table__], + ) + factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr("commands.rbac.session_factory.create_session", factory) + with factory() as session: + yield session + + +def _persist_dataset( + session: Session, + *, + dataset_id: str = "dataset-1", + tenant_id: str = "tenant-1", + permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME, + created_by: str = "creator-account-1", +) -> Dataset: + dataset = Dataset( + id=dataset_id, + tenant_id=tenant_id, + name=f"Dataset {dataset_id}", + permission=permission, + created_by=created_by, + ) + session.add(dataset) + session.commit() + return dataset + + def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]: return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()] @@ -363,56 +401,35 @@ def test_dataset_permission_rbac_migration_maps_legacy_permissions_to_enum_scope def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator( command_module, + rbac_session: Session, monkeypatch: pytest.MonkeyPatch, ) -> None: rbac_module = importlib.import_module("commands.rbac") - dataset_row = SimpleNamespace( - id="dataset-1", - tenant_id="tenant-1", - permission="only_me", - created_by="creator-account-1", - ) - execute_results = [[dataset_row], [], []] + _persist_dataset(rbac_session) calls: list[dict[str, object]] = [] - session_closed = False - - class FakeExecuteResult: - def __init__(self, rows: list[object]) -> None: - self._rows = rows - - def all(self) -> list[object]: - return self._rows - - class FakeSession: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, traceback) -> None: - nonlocal session_closed - session_closed = True - pass - - def execute(self, stmt): - return FakeExecuteResult(execute_results.pop(0)) - - class FakeSessionFactory: - @staticmethod - def create_session() -> FakeSession: - return FakeSession() + read_transaction_ended = False def fake_replace_whitelist(**kwargs): - assert session_closed is True + assert read_transaction_ended is True calls.append(kwargs) - monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory) - monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist) + def _record_transaction_end(session: Session, transaction: object) -> None: + nonlocal read_transaction_ended + del transaction + if session.get_bind() is rbac_session.get_bind(): + read_transaction_ended = True - command_module.migrate_dataset_permissions_to_rbac.callback( - tenant_id=None, - dataset_id=None, - batch_size=500, - dry_run=False, - ) + sa.event.listen(Session, "after_transaction_end", _record_transaction_end) + monkeypatch.setattr(rbac_module.RBACService.DatasetAccess, "replace_whitelist", fake_replace_whitelist) + try: + command_module.migrate_dataset_permissions_to_rbac.callback( + tenant_id=None, + dataset_id=None, + batch_size=500, + dry_run=False, + ) + finally: + sa.event.remove(Session, "after_transaction_end", _record_transaction_end) assert calls[0]["tenant_id"] == "tenant-1" assert calls[0]["account_id"] == "creator-account-1" @@ -422,41 +439,19 @@ def test_dataset_permission_rbac_migration_uses_dataset_creator_as_operator( def test_dataset_permission_rbac_migration_dry_run_outputs_structured_proposed_changes( command_module, + rbac_session: Session, monkeypatch: pytest.MonkeyPatch, ) -> None: rbac_module = importlib.import_module("commands.rbac") - dataset_row = SimpleNamespace( - id="dataset-1", - tenant_id="tenant-1", - permission="partial_members", - created_by="creator-account-1", + dataset = _persist_dataset(rbac_session, permission=DatasetPermissionEnum.PARTIAL_TEAM) + rbac_session.add( + DatasetPermission( + dataset_id=dataset.id, + account_id="member-account-1", + tenant_id=dataset.tenant_id, + ) ) - permission_row = SimpleNamespace(dataset_id="dataset-1", account_id="member-account-1") - execute_results = [[dataset_row], [permission_row], []] - - class FakeExecuteResult: - def __init__(self, rows: list[object]) -> None: - self._rows = rows - - def all(self) -> list[object]: - return self._rows - - class FakeSession: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, traceback) -> None: - pass - - def execute(self, stmt): - return FakeExecuteResult(execute_results.pop(0)) - - class FakeSessionFactory: - @staticmethod - def create_session() -> FakeSession: - return FakeSession() - - monkeypatch.setattr(rbac_module, "session_factory", FakeSessionFactory) + rbac_session.commit() monkeypatch.setattr( rbac_module.RBACService.DatasetAccess, "replace_whitelist", @@ -1306,50 +1301,36 @@ def test_provider_models_processing_uses_same_plan_locking_and_transaction_entry begin_calls: list[str] = [] configure_calls: list[str] = [] - class _FakeBeginContext: - def __init__(self, phase: str) -> None: - self._phase = phase + def _record_begin(session: Session, transaction: SessionTransaction) -> None: + if session.get_bind() is sqlite_engine and transaction.parent is None: + begin_calls.append(current_phase["name"]) - def __enter__(self) -> None: - begin_calls.append(self._phase) - - def __exit__(self, exc_type, exc, tb) -> bool: - return False - - class _FakeSession: - def __init__(self, phase: str) -> None: - self._phase = phase - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb) -> bool: - return False - - def begin(self) -> _FakeBeginContext: - return _FakeBeginContext(self._phase) - - def _fake_session_factory(engine: sa.Engine) -> _FakeSession: - return _FakeSession(current_phase["name"]) - - def _fake_build_plan(self, session, candidate, *, lock_rows: bool): + def _fake_build_plan(self, session: Session, candidate, *, lock_rows: bool): + assert session.get_bind() is sqlite_engine lock_rows_seen.append((current_phase["name"], lock_rows)) - return SimpleNamespace(group_row_ids=[str(candidate.row.id)], winner=None, loser_rows=[]) + return migration_module._ProviderModelGroupPlan( + group_row_ids=[str(candidate.row.id)], + winner=None, + loser_rows=[], + ) def _fake_emit_plan(self, plan, *, session, tx_id: str, business_key: dict[str, object]) -> None: return None - def _fake_configure(self, session) -> None: + def _fake_configure(self, session: Session) -> None: + assert session.get_bind() is sqlite_engine configure_calls.append(current_phase["name"]) - monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory) monkeypatch.setattr(migration_module.Migration, "_build_provider_model_group_plan", _fake_build_plan) monkeypatch.setattr(migration_module.Migration, "_emit_provider_model_group_plan", _fake_emit_plan) monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure) - - dry_migration._process_provider_model_group(candidate, business_key) - current_phase["name"] = "apply" - apply_migration._process_provider_model_group(candidate, business_key) + sa.event.listen(Session, "after_transaction_create", _record_begin) + try: + dry_migration._process_provider_model_group(candidate, business_key) + current_phase["name"] = "apply" + apply_migration._process_provider_model_group(candidate, business_key) + finally: + sa.event.remove(Session, "after_transaction_create", _record_begin) assert [phase for phase, _ in lock_rows_seen] == ["dry", "apply"] assert lock_rows_seen[0][1] == lock_rows_seen[1][1] @@ -1392,6 +1373,22 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch, ) -> None: + create_minimal_legacy_model_type_schema(sqlite_engine) + created_at = datetime(2025, 1, 1, 12, 0, 0) + _insert_load_balancing_model_config( + sqlite_engine, + row_id="40000000-0000-0000-0000-000000000001", + tenant_id="tenant-1", + provider_name="openai", + model_name="gpt-4o-mini", + model_type="text-generation", + name="credential", + encrypted_config="{}", + credential_id="50000000-0000-0000-0000-000000000001", + enabled=True, + created_at=created_at, + updated_at=created_at, + ) output = io.StringIO() migration = migration_module.Migration( tenant_id="tenant-1", @@ -1401,37 +1398,18 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou model_types=(ModelType.LLM,), orm_models=(migration_module.LoadBalancingModelConfig,), ) - candidate = migration_module._RowWithRawModelType( - row=SimpleNamespace(id="lb-row-1"), - raw_model_type="text-generation", - canonical_model_type=ModelType.LLM, - ) + candidate = migration._load_load_balancing_model_config_candidates(None)[0] lock_timeout_exc = OperationalError("SELECT 1", {}, SimpleNamespace(pgcode="55P03")) + transaction_begins = 0 - class _FakeBeginContext: - def __enter__(self) -> None: - return None - - def __exit__(self, exc_type, exc, tb) -> bool: - return False - - class _FakeSession: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb) -> bool: - return False - - def begin(self) -> _FakeBeginContext: - return _FakeBeginContext() - - def _fake_session_factory(engine: sa.Engine) -> _FakeSession: - return _FakeSession() + def _record_begin(session: Session, transaction: SessionTransaction) -> None: + nonlocal transaction_begins + if session.get_bind() is sqlite_engine and transaction.parent is None: + transaction_begins += 1 def _fake_reload(self, session, original_candidate, *, lock_rows: bool): raise lock_timeout_exc - monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory) monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", lambda self, session: None) monkeypatch.setattr( migration_module.Migration, @@ -1439,17 +1417,22 @@ def test_process_load_balancing_model_config_row_logs_stacktrace_for_lock_timeou _fake_reload, ) - migration._process_load_balancing_model_config_row(candidate) + sa.event.listen(Session, "after_transaction_create", _record_begin) + try: + migration._process_load_balancing_model_config_row(candidate) + finally: + sa.event.remove(Session, "after_transaction_create", _record_begin) lines = _parse_json_lines(output) assert len(lines) == 1 assert lines[0]["event"] == "lock_timeout_skipped" attrs = cast(dict[str, object], lines[0]["attrs"]) assert attrs["table_name"] == "load_balancing_model_configs" - assert attrs["id"] == "lb-row-1" + assert attrs["id"] == str(candidate.row.id) assert attrs["error"] == str(lock_timeout_exc) assert isinstance(attrs["stacktrace"], str) assert "OperationalError" in attrs["stacktrace"] + assert transaction_begins == 1 def test_process_load_balancing_model_config_row_logs_update_after_sql_execution( @@ -1457,6 +1440,23 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution sqlite_engine: sa.Engine, monkeypatch: pytest.MonkeyPatch, ) -> None: + create_minimal_legacy_model_type_schema(sqlite_engine) + created_at = datetime(2025, 1, 1, 12, 0, 0) + row_id = "40000000-0000-0000-0000-000000000002" + _insert_load_balancing_model_config( + sqlite_engine, + row_id=row_id, + tenant_id="tenant-1", + provider_name="openai", + model_name="gpt-4o-mini", + model_type="text-generation", + name="credential", + encrypted_config="{}", + credential_id="50000000-0000-0000-0000-000000000002", + enabled=True, + created_at=created_at, + updated_at=created_at, + ) migration = migration_module.Migration( tenant_id="tenant-1", engine=sqlite_engine, @@ -1465,42 +1465,33 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution model_types=(ModelType.LLM,), orm_models=(migration_module.LoadBalancingModelConfig,), ) - candidate = migration_module._RowWithRawModelType( - row=SimpleNamespace(id="lb-row-1"), - raw_model_type="text-generation", - canonical_model_type=ModelType.LLM, - ) + candidate = migration._load_load_balancing_model_config_candidates(None)[0] action_log: list[str] = [] - class _FakeBeginContext: - def __enter__(self) -> None: + def _record_begin(session: Session, transaction: SessionTransaction) -> None: + if session.get_bind() is sqlite_engine and transaction.parent is None: action_log.append("begin") - def __exit__(self, exc_type, exc, tb) -> bool: - return False - - class _FakeSession: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb) -> bool: - return False - - def begin(self) -> _FakeBeginContext: - return _FakeBeginContext() - - def execute(self, stmt) -> None: + def _record_sql( + connection: sa.Connection, + cursor: object, + statement: str, + parameters: object, + context: object, + executemany: bool, + ) -> None: + del connection, cursor, parameters, context, executemany + if statement.lstrip().upper().startswith("UPDATE"): action_log.append("sql_execute") - def _fake_session_factory(engine: sa.Engine) -> _FakeSession: - return _FakeSession() - def _fake_configure(self, session) -> None: action_log.append("configure_lock_timeout") - def _fake_reload(self, session, original_candidate, *, lock_rows: bool): + original_reload = migration_module.Migration._reload_load_balancing_model_config_candidate + + def _record_reload(self, session: Session, original_candidate, *, lock_rows: bool): action_log.append(f"reload_candidate:{lock_rows}") - return candidate + return original_reload(self, session, original_candidate, lock_rows=lock_rows) def _fake_log_row_updated(self, *args, **kwargs) -> None: action_log.append("log_row_updated") @@ -1508,12 +1499,11 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution def _fake_cache_cleanup(self, *, row_id: str, tx_id: str) -> None: action_log.append("cache_cleanup") - monkeypatch.setattr(migration_module, "_session_factory", _fake_session_factory) monkeypatch.setattr(migration_module.Migration, "_configure_lock_timeout", _fake_configure) monkeypatch.setattr( migration_module.Migration, "_reload_load_balancing_model_config_candidate", - _fake_reload, + _record_reload, ) monkeypatch.setattr(migration_module.Migration, "_log_row_updated", _fake_log_row_updated) monkeypatch.setattr( @@ -1522,7 +1512,13 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution _fake_cache_cleanup, ) - migration._process_load_balancing_model_config_row(candidate) + sa.event.listen(Session, "after_transaction_create", _record_begin) + sa.event.listen(sqlite_engine, "before_cursor_execute", _record_sql) + try: + migration._process_load_balancing_model_config_row(candidate) + finally: + sa.event.remove(sqlite_engine, "before_cursor_execute", _record_sql) + sa.event.remove(Session, "after_transaction_create", _record_begin) assert action_log == [ "begin", @@ -1532,6 +1528,10 @@ def test_process_load_balancing_model_config_row_logs_update_after_sql_execution "log_row_updated", "cache_cleanup", ] + with Session(sqlite_engine) as session: + persisted = session.get(migration_module.LoadBalancingModelConfig, row_id) + assert persisted is not None + assert persisted.model_type == ModelType.LLM def test_load_balancing_model_config_cache_delete_failure_logs_stacktrace( From e1ce808567574e755d141af5bf283b9e05601a1e Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 12:51:22 +0900 Subject: [PATCH 076/531] test: move data source controller coverage to unit tests (#38924) --- .../console/datasets/test_data_source.py | 551 +++--------------- .../console/datasets/test_data_source.py | 164 ++++-- .../datasets/test_data_source_notion_apis.py | 171 ++++++ 3 files changed, 371 insertions(+), 515 deletions(-) create mode 100644 api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py diff --git a/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py index 7c63cee4107..814886b1772 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py +++ b/api/tests/test_containers_integration_tests/controllers/console/datasets/test_data_source.py @@ -1,494 +1,85 @@ -"""Testcontainers integration tests for controllers.console.datasets.data_source endpoints.""" +"""Integration coverage for Notion page bindings backed by persisted documents.""" -from __future__ import annotations - -import inspect -from collections.abc import Iterator -from datetime import UTC, datetime -from unittest.mock import MagicMock, PropertyMock, patch +from inspect import unwrap +from unittest.mock import MagicMock, patch from uuid import uuid4 -import pytest from flask import Flask from sqlalchemy.orm import Session -from werkzeug.exceptions import NotFound -from controllers.console.datasets import data_source -from controllers.console.datasets.data_source import ( - DataSourceApi, - DataSourceNotionDatasetSyncApi, - DataSourceNotionDocumentSyncApi, - DataSourceNotionIndexingEstimateApi, - DataSourceNotionListApi, - DataSourceNotionPreviewApi, -) -from core.rag.index_processor.constant.index_type import IndexStructureType -from models import Account, DataSourceOauthBinding +from controllers.console.datasets.data_source import DataSourceNotionListApi +from models import Account from models.dataset import Document from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus -@pytest.fixture -def current_user() -> Account: - account = Account(name="Test User", email="u1@example.com") - account.id = "u1" - return account +def test_notion_page_is_marked_bound_from_persisted_document( + flask_app_with_containers: Flask, + db_session_with_containers: Session, +) -> None: + tenant_id = str(uuid4()) + dataset_id = str(uuid4()) + account = Account(name="Test User", email="user@example.com") + account.id = str(uuid4()) + document = Document( + tenant_id=tenant_id, + dataset_id=dataset_id, + position=1, + data_source_type=DataSourceType.NOTION_IMPORT, + data_source_info='{"notion_page_id": "page-1"}', + batch=f"batch-{uuid4()}", + name="Notion Page", + created_from=DocumentCreatedFrom.WEB, + created_by=str(uuid4()), + indexing_status=IndexingStatus.COMPLETED, + enabled=True, + ) + db_session_with_containers.add(document) + db_session_with_containers.commit() + runtime = MagicMock( + get_online_document_pages=lambda **_kwargs: iter( + [ + MagicMock( + result=[ + MagicMock( + workspace_id="workspace-1", + workspace_name="Workspace", + workspace_icon=None, + pages=[ + MagicMock( + page_id="page-1", + page_name="Page", + type="page", + parent_id="parent", + page_icon=None, + ) + ], + ) + ] + ) + ] + ), + datasource_provider_type=lambda: None, + ) - -@pytest.fixture -def mock_engine() -> Iterator[None]: - with patch.object( - type(data_source.db), - "engine", - new_callable=PropertyMock, - return_value=MagicMock(), + with ( + flask_app_with_containers.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"), + patch( + "controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials", + return_value={"token": "token"}, + ), + patch( + "controllers.console.datasets.data_source.DatasetService.get_dataset", + return_value=MagicMock(data_source_type="notion_import"), + ), + patch( + "core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", + return_value=runtime, + ), ): - yield - - -class TestDataSourceApi: - @pytest.fixture - def app(self, flask_app_with_containers: Flask) -> Flask: - return flask_app_with_containers - - def test_get_success(self, app: Flask) -> None: - api = DataSourceApi() - method = inspect.unwrap(api.get) - - binding = DataSourceOauthBinding( - tenant_id="tenant-1", - access_token="token", - provider="notion", - source_info={ - "workspace_name": "Workspace", - "workspace_id": "workspace-1", - "workspace_icon": None, - "total": 1, - "pages": [ - { - "page_id": "page-1", - "page_name": "Page", - "page_icon": {"type": "emoji", "emoji": "P", "url": None}, - "parent_id": "parent-1", - "type": "page", - } - ], - }, - ) - binding.id = "b1" - binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC) - binding.disabled = False - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.data_source.db.session.scalars", - return_value=MagicMock(all=lambda: [binding]), - ), - ): - response, status = method(api, "tenant-1") - - assert status == 200 - assert response["data"][0] == { - "id": "b1", - "provider": "notion", - "created_at": 1779670923, - "is_bound": True, - "disabled": False, - "source_info": { - "workspace_name": "Workspace", - "workspace_id": "workspace-1", - "workspace_icon": None, - "pages": [ - { - "page_name": "Page", - "page_id": "page-1", - "page_icon": {"type": "emoji", "url": None, "emoji": "P"}, - "parent_id": "parent-1", - "type": "page", - } - ], - "total": 1, - }, - "link": "http://localhost/console/api/oauth/data-source/notion", - } - - def test_get_no_bindings(self, app: Flask) -> None: - api = DataSourceApi() - method = inspect.unwrap(api.get) - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.data_source.db.session.scalars", - return_value=MagicMock(all=lambda: []), - ), - ): - response, status = method(api, "tenant-1") - - assert status == 200 - assert response["data"] == [] - - def test_patch_enable_binding(self, app: Flask) -> None: - api = DataSourceApi() - method = inspect.unwrap(api.patch) - - binding = MagicMock(id="b1", disabled=True) - session = MagicMock() - session.scalar.return_value = binding - - with app.test_request_context("/"): - response, status = method(api, session, "tenant-1", "b1", "enable") - - assert status == 200 - assert binding.disabled is False - - def test_patch_disable_binding(self, app: Flask) -> None: - api = DataSourceApi() - method = inspect.unwrap(api.patch) - - binding = MagicMock(id="b1", disabled=False) - session = MagicMock() - session.scalar.return_value = binding - - with app.test_request_context("/"): - response, status = method(api, session, "tenant-1", "b1", "disable") - - assert status == 200 - assert binding.disabled is True - - def test_patch_binding_not_found(self, app: Flask) -> None: - api = DataSourceApi() - method = inspect.unwrap(api.patch) - session = MagicMock() - session.scalar.return_value = None - - with app.test_request_context("/"): - with pytest.raises(NotFound): - method(api, session, "tenant-1", "b1", "enable") - - def test_patch_enable_already_enabled(self, app: Flask) -> None: - api = DataSourceApi() - method = inspect.unwrap(api.patch) - - binding = MagicMock(id="b1", disabled=False) - session = MagicMock() - session.scalar.return_value = binding - - with app.test_request_context("/"): - with pytest.raises(ValueError): - method(api, session, "tenant-1", "b1", "enable") - - def test_patch_disable_already_disabled(self, app: Flask) -> None: - api = DataSourceApi() - method = inspect.unwrap(api.patch) - - binding = MagicMock(id="b1", disabled=True) - session = MagicMock() - session.scalar.return_value = binding - - with app.test_request_context("/"): - with pytest.raises(ValueError): - method(api, session, "tenant-1", "b1", "disable") - - -class TestDataSourceNotionListApi: - @pytest.fixture - def app(self, flask_app_with_containers: Flask) -> Flask: - return flask_app_with_containers - - def test_get_credential_not_found(self, app: Flask, current_user: Account) -> None: - api = DataSourceNotionListApi() - method = inspect.unwrap(api.get) - - with ( - app.test_request_context("/?credential_id=c1"), - patch( - "controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials", - return_value=None, - ), - ): - with pytest.raises(NotFound): - method(api, MagicMock(), "tenant-1", current_user) - - def test_get_success_no_dataset_id(self, app: Flask, current_user: Account, mock_engine: None) -> None: - api = DataSourceNotionListApi() - method = inspect.unwrap(api.get) - - page = MagicMock( - page_id="p1", - page_name="Page 1", - type="page", - parent_id="parent", - page_icon=None, + response, status = unwrap(DataSourceNotionListApi().get)( + DataSourceNotionListApi(), db_session_with_containers, tenant_id, account ) - online_document_message = MagicMock( - result=[ - MagicMock( - workspace_id="w1", - workspace_name="My Workspace", - workspace_icon="icon", - pages=[page], - ) - ] - ) - - with ( - app.test_request_context("/?credential_id=c1"), - patch( - "controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials", - return_value={"token": "t"}, - ), - patch( - "core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", - return_value=MagicMock( - get_online_document_pages=lambda **kw: iter([online_document_message]), - datasource_provider_type=lambda: None, - ), - ), - ): - response, status = method(api, MagicMock(), "tenant-1", current_user) - - assert status == 200 - - def test_get_success_with_dataset_id( - self, app: Flask, current_user: Account, mock_engine: None, db_session_with_containers: Session - ) -> None: - api = DataSourceNotionListApi() - method = inspect.unwrap(api.get) - tenant_id = str(uuid4()) - dataset_id = str(uuid4()) - - page = MagicMock( - page_id="p1", - page_name="Page 1", - type="page", - parent_id="parent", - page_icon=None, - ) - - online_document_message = MagicMock( - result=[ - MagicMock( - workspace_id="w1", - workspace_name="My Workspace", - workspace_icon="icon", - pages=[page], - ) - ] - ) - - dataset = MagicMock(data_source_type="notion_import") - document = Document( - tenant_id=tenant_id, - dataset_id=dataset_id, - position=1, - data_source_type=DataSourceType.NOTION_IMPORT, - data_source_info='{"notion_page_id": "p1"}', - batch=f"batch-{uuid4()}", - name="Notion Page", - created_from=DocumentCreatedFrom.WEB, - created_by=str(uuid4()), - indexing_status=IndexingStatus.COMPLETED, - enabled=True, - ) - db_session_with_containers.add(document) - db_session_with_containers.commit() - - with ( - app.test_request_context(f"/?credential_id=c1&dataset_id={dataset_id}"), - patch( - "controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials", - return_value={"token": "t"}, - ), - patch( - "controllers.console.datasets.data_source.DatasetService.get_dataset", - return_value=dataset, - ), - patch( - "core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", - return_value=MagicMock( - get_online_document_pages=lambda **kw: iter([online_document_message]), - datasource_provider_type=lambda: None, - ), - ), - ): - response, status = method(api, db_session_with_containers, tenant_id, current_user) - - assert status == 200 - - def test_get_invalid_dataset_type(self, app: Flask, current_user: Account, mock_engine: None) -> None: - api = DataSourceNotionListApi() - method = inspect.unwrap(api.get) - - dataset = MagicMock(data_source_type="other_type") - - with ( - app.test_request_context("/?credential_id=c1&dataset_id=ds1"), - patch( - "controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials", - return_value={"token": "t"}, - ), - patch( - "controllers.console.datasets.data_source.DatasetService.get_dataset", - return_value=dataset, - ), - ): - with pytest.raises(ValueError): - method(api, MagicMock(), "tenant-1", current_user) - - -class TestDataSourceNotionPreviewApi: - @pytest.fixture - def app(self, flask_app_with_containers: Flask) -> Flask: - return flask_app_with_containers - - def test_get_preview_success(self, app: Flask) -> None: - api = DataSourceNotionPreviewApi() - method = inspect.unwrap(api.get) - - extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")]) - - with ( - app.test_request_context("/?credential_id=c1"), - patch( - "controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials", - return_value={"integration_secret": "t"}, - ), - patch( - "controllers.console.datasets.data_source.NotionExtractor", - return_value=extractor, - ), - ): - response, status = method(api, "tenant-1", "p1", "page") - - assert status == 200 - - -class TestDataSourceNotionIndexingEstimateApi: - @pytest.fixture - def app(self, flask_app_with_containers: Flask) -> Flask: - return flask_app_with_containers - - def test_post_indexing_estimate_success(self, app: Flask) -> None: - api = DataSourceNotionIndexingEstimateApi() - method = inspect.unwrap(api.post) - - empty_rules: dict[str, object] = {} - payload: dict[str, object] = { - "notion_info_list": [ - { - "workspace_id": "w1", - "credential_id": "c1", - "pages": [{"page_id": "p1", "type": "page"}], - } - ], - "process_rule": {"rules": empty_rules}, - "doc_form": IndexStructureType.PARAGRAPH_INDEX, - "doc_language": "English", - } - - with ( - app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}), - patch( - "controllers.console.datasets.data_source.DocumentService.estimate_args_validate", - ), - patch( - "controllers.console.datasets.data_source.IndexingRunner.indexing_estimate", - return_value=MagicMock(model_dump=lambda: {"total_pages": 1}), - ), - ): - response, status = method(api, MagicMock(), "tenant-1") - - assert status == 200 - - -class TestDataSourceNotionDatasetSyncApi: - @pytest.fixture - def app(self, flask_app_with_containers: Flask) -> Flask: - return flask_app_with_containers - - def test_get_success(self, app: Flask) -> None: - api = DataSourceNotionDatasetSyncApi() - method = inspect.unwrap(api.get) - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.data_source.DatasetService.get_dataset", - return_value=MagicMock(), - ), - patch( - "controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id", - return_value=[MagicMock(id="d1")], - ), - patch( - "controllers.console.datasets.data_source.document_indexing_sync_task.delay", - return_value=None, - ), - ): - response, status = method(api, MagicMock(), "ds-1") - - assert status == 200 - - def test_get_dataset_not_found(self, app: Flask) -> None: - api = DataSourceNotionDatasetSyncApi() - method = inspect.unwrap(api.get) - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.data_source.DatasetService.get_dataset", - return_value=None, - ), - ): - with pytest.raises(NotFound): - method(api, MagicMock(), "ds-1") - - -class TestDataSourceNotionDocumentSyncApi: - @pytest.fixture - def app(self, flask_app_with_containers: Flask) -> Flask: - return flask_app_with_containers - - def test_get_success(self, app: Flask) -> None: - api = DataSourceNotionDocumentSyncApi() - method = inspect.unwrap(api.get) - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.data_source.DatasetService.get_dataset", - return_value=MagicMock(), - ), - patch( - "controllers.console.datasets.data_source.DocumentService.get_document", - return_value=MagicMock(), - ), - patch( - "controllers.console.datasets.data_source.document_indexing_sync_task.delay", - return_value=None, - ), - ): - response, status = method(api, MagicMock(), "ds-1", "doc-1") - - assert status == 200 - - def test_get_document_not_found(self, app: Flask) -> None: - api = DataSourceNotionDocumentSyncApi() - method = inspect.unwrap(api.get) - - with ( - app.test_request_context("/"), - patch( - "controllers.console.datasets.data_source.DatasetService.get_dataset", - return_value=MagicMock(), - ), - patch( - "controllers.console.datasets.data_source.DocumentService.get_document", - return_value=None, - ), - ): - with pytest.raises(NotFound): - method(api, MagicMock(), "ds-1", "doc-1") + assert status == 200 + assert response["notion_info"][0]["pages"][0]["is_bound"] is True diff --git a/api/tests/unit_tests/controllers/console/datasets/test_data_source.py b/api/tests/unit_tests/controllers/console/datasets/test_data_source.py index a6cb79417a7..55ee174355c 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_data_source.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_data_source.py @@ -1,18 +1,22 @@ from __future__ import annotations import inspect -from collections.abc import Callable +from collections.abc import Callable, Iterator from datetime import UTC, datetime -from typing import cast +from typing import Literal, cast from unittest.mock import MagicMock, PropertyMock, patch -from uuid import uuid4 +from uuid import UUID import pytest from flask import Flask +from sqlalchemy import select +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound from controllers.console.datasets import data_source as module from controllers.console.datasets.data_source import DataSourceApi, DataSourceNotionListApi from models import Account, DataSourceOauthBinding +from models.engine import db ControllerMethod = Callable[..., tuple[dict[str, object], int]] @@ -22,10 +26,15 @@ def unwrap(func: object) -> ControllerMethod: @pytest.fixture -def flask_app() -> Flask: +def flask_app() -> Iterator[Flask]: app = Flask(__name__) app.config["TESTING"] = True - return app + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + db.init_app(app) + + with app.app_context(): + DataSourceOauthBinding.__table__.create(db.engine) + yield app @pytest.fixture @@ -35,9 +44,13 @@ def current_user() -> Account: return account -def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> None: +TENANT_ID = "11111111-1111-1111-1111-111111111111" +BINDING_ID = "22222222-2222-2222-2222-222222222222" + + +def _add_binding(session: Session, *, disabled: bool) -> DataSourceOauthBinding: binding = DataSourceOauthBinding( - tenant_id="tenant-1", + tenant_id=TENANT_ID, access_token="token", provider="notion", source_info={ @@ -55,24 +68,31 @@ def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> } ], }, + disabled=disabled, ) - binding.id = "binding-1" + binding.id = BINDING_ID binding.created_at = datetime(2026, 5, 25, 1, 2, 3, tzinfo=UTC) - binding.disabled = False + session.add(binding) + session.commit() + return binding - with ( - flask_app.test_request_context("/"), - patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [binding])), - ): - response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1") + +def test_get_data_source_integrates_serializes_orm_binding( + flask_app: Flask, +) -> None: + binding = _add_binding(db.session, disabled=False) + expected_created_at = int(binding.created_at.timestamp()) + + with flask_app.test_request_context("/"): + response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID) assert status == 200 assert response == { "data": [ { - "id": "binding-1", + "id": BINDING_ID, "provider": "notion", - "created_at": 1779670923, + "created_at": expected_created_at, "is_bound": True, "disabled": False, "source_info": { @@ -96,34 +116,75 @@ def test_get_data_source_integrates_serializes_orm_binding(flask_app: Flask) -> } -def test_get_data_source_integrates_preserves_empty_list_when_no_binding(flask_app: Flask) -> None: - with ( - flask_app.test_request_context("/"), - patch.object(module.db.session, "scalars", return_value=MagicMock(all=lambda: [])), - ): - response, status = unwrap(DataSourceApi().get)(DataSourceApi(), "tenant-1") +def test_get_data_source_integrates_preserves_empty_list_when_no_binding( + flask_app: Flask, +) -> None: + with flask_app.test_request_context("/"): + response, status = unwrap(DataSourceApi().get)(DataSourceApi(), TENANT_ID) assert status == 200 assert response == {"data": []} -def test_patch_data_source_binding_uses_injected_session(flask_app: Flask) -> None: - binding = MagicMock(disabled=True) - session = MagicMock() - session.scalar.return_value = binding +@pytest.mark.parametrize( + ("disabled", "action", "expected_disabled"), + [(True, "enable", False), (False, "disable", True)], +) +@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True) +def test_patch_data_source_binding_updates_state( + flask_app: Flask, + sqlite_session: Session, + disabled: bool, + action: Literal["enable", "disable"], + expected_disabled: bool, +) -> None: + _add_binding(sqlite_session, disabled=disabled) + sqlite_session.expunge_all() with flask_app.test_request_context("/"): - response, status = unwrap(DataSourceApi().patch)(DataSourceApi(), session, "tenant-1", uuid4(), "enable") + response, status = unwrap(DataSourceApi().patch)( + DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action + ) + sqlite_session.flush() + sqlite_session.expire_all() + binding = sqlite_session.scalar(select(DataSourceOauthBinding).where(DataSourceOauthBinding.id == BINDING_ID)) assert status == 200 assert response == {"result": "success"} - assert binding.disabled is False - session.scalar.assert_called_once() - session.add.assert_not_called() - session.commit.assert_not_called() + assert binding is not None + assert binding.disabled is expected_disabled -def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask, current_user: Account) -> None: +@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True) +def test_patch_data_source_binding_rejects_unknown_binding( + flask_app: Flask, + sqlite_session: Session, +) -> None: + with flask_app.test_request_context("/"), pytest.raises(NotFound, match="Data source binding not found"): + unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), "enable") + + +@pytest.mark.parametrize(("disabled", "action"), [(False, "enable"), (True, "disable")]) +@pytest.mark.parametrize("sqlite_session", [(DataSourceOauthBinding,)], indirect=True) +def test_patch_data_source_binding_rejects_current_state( + flask_app: Flask, + sqlite_session: Session, + disabled: bool, + action: Literal["enable", "disable"], +) -> None: + _add_binding(sqlite_session, disabled=disabled) + sqlite_session.expunge_all() + + with flask_app.test_request_context("/"), pytest.raises(ValueError): + unwrap(DataSourceApi().patch)(DataSourceApi(), sqlite_session, TENANT_ID, UUID(BINDING_ID), action) + + +@pytest.mark.parametrize("sqlite_session", [()], indirect=True) +def test_notion_pre_import_pages_serializes_frontend_list_shape( + flask_app: Flask, + current_user: Account, + sqlite_session: Session, +) -> None: page = MagicMock( page_id="page-1", page_name="Page", @@ -145,8 +206,6 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask get_online_document_pages=MagicMock(return_value=iter([online_document_message])), datasource_provider_type=MagicMock(return_value="online_document"), ) - session = MagicMock() - with ( flask_app.test_request_context("/?credential_id=credential-1"), patch.object( @@ -158,7 +217,7 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask patch("core.datasource.datasource_manager.DatasourceManager.get_datasource_runtime", return_value=runtime), ): response, status = unwrap(DataSourceNotionListApi().get)( - DataSourceNotionListApi(), session, "tenant-1", current_user + DataSourceNotionListApi(), sqlite_session, "tenant-1", current_user ) assert status == 200 @@ -183,3 +242,38 @@ def test_notion_pre_import_pages_serializes_frontend_list_shape(flask_app: Flask } runtime.get_online_document_pages.assert_called_once() assert runtime.get_online_document_pages.call_args.kwargs["datasource_parameters"] == {} + + +@pytest.mark.parametrize("sqlite_session", [()], indirect=True) +def test_notion_pre_import_pages_rejects_missing_credential( + flask_app: Flask, + current_user: Account, + sqlite_session: Session, +) -> None: + with ( + flask_app.test_request_context("/?credential_id=credential-1"), + patch.object(module.DatasourceProviderService, "get_datasource_credentials", return_value=None), + pytest.raises(NotFound, match="Credential not found"), + ): + unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user) + + +@pytest.mark.parametrize("sqlite_session", [()], indirect=True) +def test_notion_pre_import_pages_rejects_non_notion_dataset( + flask_app: Flask, + current_user: Account, + sqlite_session: Session, +) -> None: + dataset = MagicMock(data_source_type="other_type") + + with ( + flask_app.test_request_context("/?credential_id=credential-1&dataset_id=dataset-1"), + patch.object( + module.DatasourceProviderService, + "get_datasource_credentials", + return_value={"token": "token"}, + ), + patch.object(module.DatasetService, "get_dataset", return_value=dataset), + pytest.raises(ValueError, match="Dataset is not notion type"), + ): + unwrap(DataSourceNotionListApi().get)(DataSourceNotionListApi(), sqlite_session, TENANT_ID, current_user) diff --git a/api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py b/api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py new file mode 100644 index 00000000000..d86a1093cc8 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/datasets/test_data_source_notion_apis.py @@ -0,0 +1,171 @@ +"""Unit tests for controllers.console.datasets.data_source Notion endpoints.""" + +from __future__ import annotations + +import inspect +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask +from sqlalchemy.orm import Session +from werkzeug.exceptions import NotFound + +from controllers.console.datasets.data_source import ( + DataSourceNotionDatasetSyncApi, + DataSourceNotionDocumentSyncApi, + DataSourceNotionIndexingEstimateApi, + DataSourceNotionPreviewApi, +) +from core.rag.index_processor.constant.index_type import IndexStructureType +from models import Account + + +@pytest.fixture +def current_user() -> Account: + account = Account(name="Test User", email="u1@example.com") + account.id = "u1" + return account + + +class TestDataSourceNotionPreviewApi: + def test_get_preview_success(self, app: Flask) -> None: + api = DataSourceNotionPreviewApi() + method = inspect.unwrap(api.get) + + extractor = MagicMock(extract=lambda: [MagicMock(page_content="hello")]) + + with ( + app.test_request_context("/?credential_id=c1"), + patch( + "controllers.console.datasets.data_source.DatasourceProviderService.get_datasource_credentials", + return_value={"integration_secret": "t"}, + ), + patch( + "controllers.console.datasets.data_source.NotionExtractor", + return_value=extractor, + ), + ): + response, status = method(api, "tenant-1", "p1", "page") + + assert status == 200 + + +class TestDataSourceNotionIndexingEstimateApi: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_post_indexing_estimate_success(self, app: Flask, sqlite_session: Session) -> None: + api = DataSourceNotionIndexingEstimateApi() + method = inspect.unwrap(api.post) + + empty_rules: dict[str, object] = {} + payload: dict[str, object] = { + "notion_info_list": [ + { + "workspace_id": "w1", + "credential_id": "c1", + "pages": [{"page_id": "p1", "type": "page"}], + } + ], + "process_rule": {"rules": empty_rules}, + "doc_form": IndexStructureType.PARAGRAPH_INDEX, + "doc_language": "English", + } + + with ( + app.test_request_context("/", method="POST", json=payload, headers={"Content-Type": "application/json"}), + patch( + "controllers.console.datasets.data_source.DocumentService.estimate_args_validate", + ), + patch( + "controllers.console.datasets.data_source.IndexingRunner.indexing_estimate", + return_value=MagicMock(model_dump=lambda: {"total_pages": 1}), + ), + ): + response, status = method(api, sqlite_session, "tenant-1") + + assert status == 200 + + +class TestDataSourceNotionDatasetSyncApi: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_get_success(self, app: Flask, sqlite_session: Session) -> None: + api = DataSourceNotionDatasetSyncApi() + method = inspect.unwrap(api.get) + + with ( + app.test_request_context("/"), + patch( + "controllers.console.datasets.data_source.DatasetService.get_dataset", + return_value=MagicMock(), + ), + patch( + "controllers.console.datasets.data_source.DocumentService.get_document_by_dataset_id", + return_value=[MagicMock(id="d1")], + ), + patch( + "controllers.console.datasets.data_source.document_indexing_sync_task.delay", + return_value=None, + ), + ): + response, status = method(api, sqlite_session, "ds-1") + + assert status == 200 + + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_get_dataset_not_found(self, app: Flask, sqlite_session: Session) -> None: + api = DataSourceNotionDatasetSyncApi() + method = inspect.unwrap(api.get) + + with ( + app.test_request_context("/"), + patch( + "controllers.console.datasets.data_source.DatasetService.get_dataset", + return_value=None, + ), + ): + with pytest.raises(NotFound): + method(api, sqlite_session, "ds-1") + + +class TestDataSourceNotionDocumentSyncApi: + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_get_success(self, app: Flask, sqlite_session: Session) -> None: + api = DataSourceNotionDocumentSyncApi() + method = inspect.unwrap(api.get) + + with ( + app.test_request_context("/"), + patch( + "controllers.console.datasets.data_source.DatasetService.get_dataset", + return_value=MagicMock(), + ), + patch( + "controllers.console.datasets.data_source.DocumentService.get_document", + return_value=MagicMock(), + ), + patch( + "controllers.console.datasets.data_source.document_indexing_sync_task.delay", + return_value=None, + ), + ): + response, status = method(api, sqlite_session, "ds-1", "doc-1") + + assert status == 200 + + @pytest.mark.parametrize("sqlite_session", [()], indirect=True) + def test_get_document_not_found(self, app: Flask, sqlite_session: Session) -> None: + api = DataSourceNotionDocumentSyncApi() + method = inspect.unwrap(api.get) + + with ( + app.test_request_context("/"), + patch( + "controllers.console.datasets.data_source.DatasetService.get_dataset", + return_value=MagicMock(), + ), + patch( + "controllers.console.datasets.data_source.DocumentService.get_document", + return_value=None, + ), + ): + with pytest.raises(NotFound): + method(api, sqlite_session, "ds-1", "doc-1") From e6e5d761c2ba066e887d394dd42089f973100045 Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 28 Jul 2026 12:01:12 +0800 Subject: [PATCH 077/531] fix: prevent Safari from clipping the settings close button (#39664) Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../header/account-setting/index.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 39e8ea60306..7749686568e 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -211,6 +211,18 @@ export default function AccountSetting({ return ( +
+ +
ESC
+
@@ -275,18 +287,6 @@ export default function AccountSetting({
)}
-
- -
ESC
-
{activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && ( From 49e74e2f58ad1e47158ef202dec0897993b074fc Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 28 Jul 2026 12:02:28 +0800 Subject: [PATCH 078/531] fix: prevent the model selector footer from covering options (#39668) --- .../model-provider-page/model-selector/popup.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index ae4f4d5067f..c6f7c08760d 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -272,8 +272,10 @@ function Popup({ return ( - {showCreditsExhaustedAlert && } $['modelProvider.models'], { ns: 'common' })}> + {showCreditsExhaustedAlert && ( + + )}
{filteredModelList.map((model) => ( From dd28b0d165c18b7fb3bb2b5c6e9109a44a4944b5 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 13:09:32 +0900 Subject: [PATCH 079/531] test: move OAuth server service coverage to unit tests (#38931) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Byron.wang --- .../services/test_oauth_server_service.py | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) rename api/tests/{test_containers_integration_tests => unit_tests}/services/test_oauth_server_service.py (78%) diff --git a/api/tests/test_containers_integration_tests/services/test_oauth_server_service.py b/api/tests/unit_tests/services/test_oauth_server_service.py similarity index 78% rename from api/tests/test_containers_integration_tests/services/test_oauth_server_service.py rename to api/tests/unit_tests/services/test_oauth_server_service.py index 7ea52ff6fef..ebc7b6501fe 100644 --- a/api/tests/test_containers_integration_tests/services/test_oauth_server_service.py +++ b/api/tests/unit_tests/services/test_oauth_server_service.py @@ -1,16 +1,20 @@ -"""Testcontainers integration tests for OAuthServerService.""" +"""Unit tests for OAuthServerService with SQLite-backed database access.""" from __future__ import annotations import uuid +from collections.abc import Iterator from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from flask import Flask +from sqlalchemy import Engine from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest +from models.engine import db from models.model import OAuthProviderApp from services.oauth_server import ( OAUTH_ACCESS_TOKEN_EXPIRES_IN, @@ -23,10 +27,24 @@ from services.oauth_server import ( ) -class TestOAuthServerServiceGetProviderApp: - """DB-backed tests for get_oauth_provider_app.""" +@pytest.fixture +def oauth_db() -> Iterator[Session]: + """Provide the production database extension with an isolated SQLite provider table.""" + app = Flask(__name__) + app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + db.init_app(app) - def _create_oauth_provider_app(self, db_session_with_containers: Session, *, client_id: str) -> OAuthProviderApp: + with app.app_context(): + OAuthProviderApp.__table__.create(db.engine) + with Session(db.engine, expire_on_commit=False) as session: + yield session + + +class TestOAuthServerServiceGetProviderApp: + """Verify provider lookup against a real SQLAlchemy database.""" + + def test_get_oauth_provider_app_returns_app_when_exists(self, oauth_db: Session) -> None: + client_id = f"client-{uuid4()}" app = OAuthProviderApp( app_icon="icon.png", client_id=client_id, @@ -35,35 +53,30 @@ class TestOAuthServerServiceGetProviderApp: redirect_uris=["https://example.com/callback"], scope="read", ) - db_session_with_containers.add(app) - db_session_with_containers.commit() - return app - - def test_get_oauth_provider_app_returns_app_when_exists(self, db_session_with_containers: Session): - client_id = f"client-{uuid4()}" - created = self._create_oauth_provider_app(db_session_with_containers, client_id=client_id) + oauth_db.add(app) + oauth_db.commit() result = OAuthServerService.get_oauth_provider_app(client_id) assert result is not None assert result.client_id == client_id - assert result.id == created.id + assert result.id == app.id - def test_get_oauth_provider_app_returns_none_when_not_exists(self, db_session_with_containers: Session): + def test_get_oauth_provider_app_returns_none_when_not_exists(self, oauth_db: Session) -> None: result = OAuthServerService.get_oauth_provider_app(f"nonexistent-{uuid4()}") assert result is None class TestOAuthServerServiceTokenOperations: - """Redis-backed tests for token sign/validate operations.""" + """Verify Redis-backed token signing and validation branches.""" @pytest.fixture def mock_redis(self): with patch("services.oauth_server.redis_client") as mock: yield mock - def test_sign_authorization_code_stores_and_returns_code(self, mock_redis): + def test_sign_authorization_code_stores_and_returns_code(self, mock_redis) -> None: deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000111") with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid): code = OAuthServerService.sign_oauth_authorization_code("client-1", "user-1") @@ -75,7 +88,7 @@ class TestOAuthServerServiceTokenOperations: ex=600, ) - def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis): + def test_sign_access_token_raises_bad_request_for_invalid_code(self, mock_redis) -> None: mock_redis.get.return_value = None with pytest.raises(BadRequest, match="invalid code"): @@ -85,14 +98,13 @@ class TestOAuthServerServiceTokenOperations: client_id="client-1", ) - def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis): + def test_sign_access_token_issues_tokens_for_valid_code(self, mock_redis) -> None: token_uuids = [ uuid.UUID("00000000-0000-0000-0000-000000000201"), uuid.UUID("00000000-0000-0000-0000-000000000202"), ] with patch("services.oauth_server.uuid.uuid4", side_effect=token_uuids): mock_redis.get.return_value = b"user-1" - access_token, refresh_token = OAuthServerService.sign_oauth_access_token( grant_type=OAuthGrantType.AUTHORIZATION_CODE, code="code-1", @@ -114,7 +126,7 @@ class TestOAuthServerServiceTokenOperations: ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN, ) - def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis): + def test_sign_access_token_raises_bad_request_for_invalid_refresh_token(self, mock_redis) -> None: mock_redis.get.return_value = None with pytest.raises(BadRequest, match="invalid refresh token"): @@ -124,11 +136,10 @@ class TestOAuthServerServiceTokenOperations: client_id="client-1", ) - def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis): + def test_sign_access_token_issues_new_token_for_valid_refresh(self, mock_redis) -> None: deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000301") with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid): mock_redis.get.return_value = b"user-1" - access_token, returned_refresh = OAuthServerService.sign_oauth_access_token( grant_type=OAuthGrantType.REFRESH_TOKEN, refresh_token="refresh-1", @@ -138,14 +149,14 @@ class TestOAuthServerServiceTokenOperations: assert access_token == str(deterministic_uuid) assert returned_refresh == "refresh-1" - def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis): + def test_sign_access_token_returns_none_for_unknown_grant_type(self, mock_redis) -> None: grant_type = cast(OAuthGrantType, "invalid-grant-type") result = OAuthServerService.sign_oauth_access_token(grant_type=grant_type, client_id="client-1") assert result is None - def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis): + def test_sign_refresh_token_stores_with_expected_expiry(self, mock_redis) -> None: deterministic_uuid = uuid.UUID("00000000-0000-0000-0000-000000000401") with patch("services.oauth_server.uuid.uuid4", return_value=deterministic_uuid): refresh_token = OAuthServerService._sign_oauth_refresh_token("client-2", "user-2") @@ -157,22 +168,21 @@ class TestOAuthServerServiceTokenOperations: ex=OAUTH_REFRESH_TOKEN_EXPIRES_IN, ) - def test_validate_access_token_returns_none_when_not_found(self, mock_redis, db_session_with_containers: Session): + def test_validate_access_token_returns_none_when_not_found(self, mock_redis, sqlite_engine: Engine) -> None: mock_redis.get.return_value = None - session = MagicMock() - result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", db_session_with_containers) + with Session(sqlite_engine) as session: + result = OAuthServerService.validate_oauth_access_token("client-1", "missing-token", session) assert result is None - def test_validate_access_token_loads_user_when_exists(self, mock_redis, db_session_with_containers: Session): + def test_validate_access_token_loads_user_when_exists(self, mock_redis, sqlite_engine: Engine) -> None: mock_redis.get.return_value = b"user-88" expected_user = MagicMock() - with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load: - result = OAuthServerService.validate_oauth_access_token( - "client-1", "access-token", db_session_with_containers - ) + with Session(sqlite_engine) as session: + with patch("services.oauth_server.AccountService.load_user", return_value=expected_user) as mock_load: + result = OAuthServerService.validate_oauth_access_token("client-1", "access-token", session) + mock_load.assert_called_once_with("user-88", session) assert result is expected_user - mock_load.assert_called_once_with("user-88", db_session_with_containers) From 1e5e47b88923d76054f8414c1105fbe96a065459 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:13:20 +0800 Subject: [PATCH 080/531] fix: validate plugin installation scope (#39669) --- api/core/plugin/plugin_service.py | 46 ++++--- api/services/feature_service.py | 57 ++++++-- .../test_plugin_service_installation.py | 64 +++++---- ..._service_plugin_installation_permission.py | 92 +++++++++++++ .../use-install-plugin-limit.spec.ts | 124 +++++++----------- .../hooks/use-install-plugin-limit.tsx | 77 +++++------ 6 files changed, 289 insertions(+), 171 deletions(-) create mode 100644 api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index e2cf702c5bd..55738631891 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -66,7 +66,7 @@ from services.enterprise.plugin_manager_service import ( PreUninstallPluginRequest, ) from services.errors.plugin import PluginInstallationForbiddenError -from services.feature_service import FeatureService, PluginInstallationScope +from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope logger = logging.getLogger(__name__) _provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity]) @@ -604,22 +604,30 @@ class PluginService: return result @staticmethod - def _check_marketplace_only_permission(): + def _check_marketplace_only_permission() -> None: """ Check if the marketplace only permission is enabled """ - features = FeatureService.get_system_features() - if features.plugin_installation_permission.restrict_to_marketplace_only: + permission = PluginService._get_plugin_installation_permission() + if permission.restrict_to_marketplace_only: raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only") @staticmethod - def _check_plugin_installation_scope(plugin_verification: PluginVerification | None): + def _get_plugin_installation_permission() -> PluginInstallationPermissionModel: + """Resolve the validated policy and reject deny-all before any installation side effect.""" + permission = FeatureService.get_plugin_installation_permission() + if permission.plugin_installation_scope == PluginInstallationScope.NONE: + raise PluginInstallationForbiddenError("Installing plugins is not allowed") + return permission + + @staticmethod + def _check_plugin_installation_scope(plugin_verification: PluginVerification | None) -> None: """ Check the plugin installation scope """ - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() - match features.plugin_installation_permission.plugin_installation_scope: + match permission.plugin_installation_scope: case PluginInstallationScope.OFFICIAL_ONLY: if ( plugin_verification is None @@ -634,10 +642,10 @@ class PluginService: raise PluginInstallationForbiddenError( "Plugin installation is restricted to official and specific partners" ) - case PluginInstallationScope.NONE: - raise PluginInstallationForbiddenError("Installing plugins is not allowed") case PluginInstallationScope.ALL: pass + case _: + raise PluginInstallationForbiddenError("Plugin installation policy is invalid") @staticmethod def get_debugging_key(tenant_id: str) -> str: @@ -907,7 +915,7 @@ class PluginService: # check if plugin pkg is already downloaded manager = PluginInstaller() - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() try: manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier) @@ -919,7 +927,7 @@ class PluginService: response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) # check if the plugin is available to install @@ -974,11 +982,11 @@ class PluginService: """ PluginService._check_marketplace_only_permission() manager = PluginInstaller() - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) PluginService._check_plugin_installation_scope(response.verification) @@ -996,13 +1004,13 @@ class PluginService: pkg = download_with_size_limit( f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE ) - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() manager = PluginInstaller() response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) PluginService._check_plugin_installation_scope(response.verification) @@ -1076,7 +1084,7 @@ class PluginService: if not dify_config.MARKETPLACE_ENABLED: raise ValueError("marketplace is not enabled") - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() manager = PluginInstaller() try: @@ -1086,7 +1094,7 @@ class PluginService: response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) # check if the plugin is available to install PluginService._check_plugin_installation_scope(response.verification) @@ -1108,7 +1116,7 @@ class PluginService: # collect actual plugin_unique_identifiers actual_plugin_unique_identifiers = [] metas = [] - features = FeatureService.get_system_features() + permission = PluginService._get_plugin_installation_permission() # check if already downloaded for plugin_unique_identifier in plugin_unique_identifiers: @@ -1126,7 +1134,7 @@ class PluginService: response = manager.upload_pkg( tenant_id, pkg, - verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only, + verify_signature=permission.restrict_to_marketplace_only, ) # check if the plugin is available to install PluginService._check_plugin_installation_scope(response.verification) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index d80d0344788..954d182d74e 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -1,6 +1,8 @@ +import logging +from collections.abc import Mapping from enum import StrEnum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, ValidationError from configs import dify_config from constants.dsl_version import CURRENT_APP_DSL_VERSION @@ -10,6 +12,8 @@ from enums.hosted_provider import HostedTrialProvider from services.billing_service import BillingInfo, BillingService from services.enterprise.enterprise_service import EnterpriseService +logger = logging.getLogger(__name__) + class FeatureResponseModel(BaseModel): model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=()) @@ -131,6 +135,13 @@ class PluginInstallationPermissionModel(FeatureResponseModel): restrict_to_marketplace_only: bool = False +class _EnterprisePluginInstallationPermission(BaseModel): + model_config = ConfigDict(extra="ignore") + + plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope") + restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True) + + class FeatureModel(FeatureResponseModel): billing: BillingModel = BillingModel() education: EducationModel = EducationModel() @@ -285,6 +296,14 @@ class FeatureService: """Return whether Enterprise plugin credential policies must be enforced.""" return dify_config.ENTERPRISE_ENABLED + @classmethod + def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel: + """Resolve the validated deployment-wide plugin installation policy.""" + if not dify_config.ENTERPRISE_ENABLED: + return PluginInstallationPermissionModel() + + return cls._resolve_plugin_installation_permission(EnterpriseService.get_info()) + @classmethod def get_license(cls) -> LicenseModel: """Return full license detail. Enterprise-only; requires an authenticated caller. @@ -452,6 +471,33 @@ class FeatureService: ) return license_model + @classmethod + def _resolve_plugin_installation_permission( + cls, enterprise_info: Mapping[str, object] + ) -> PluginInstallationPermissionModel: + if "PluginInstallationPermission" not in enterprise_info: + return PluginInstallationPermissionModel() + + try: + permission = _EnterprisePluginInstallationPermission.model_validate( + enterprise_info["PluginInstallationPermission"] + ) + except ValidationError as exc: + # Do not attach the exception because it may contain raw Enterprise configuration values. + logger.error( # noqa: TRY400 + "Invalid Enterprise plugin installation permission; denying all plugin installations: %s", + exc.errors(include_input=False), + ) + return PluginInstallationPermissionModel( + plugin_installation_scope=PluginInstallationScope.NONE, + restrict_to_marketplace_only=True, + ) + + return PluginInstallationPermissionModel( + plugin_installation_scope=permission.plugin_installation_scope, + restrict_to_marketplace_only=permission.restrict_to_marketplace_only, + ) + @classmethod def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel): enterprise_info = EnterpriseService.get_info() @@ -499,11 +545,4 @@ class FeatureService: status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) ) - if "PluginInstallationPermission" in enterprise_info: - plugin_installation_info = enterprise_info["PluginInstallationPermission"] - features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[ - "pluginInstallationScope" - ] - features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[ - "restrictToMarketplaceOnly" - ] + features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info) diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py index ca1a227b010..9f6cf4f36f9 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py @@ -8,6 +8,7 @@ verification, marketplace upgrade flows, and uninstall with credential cleanup. from __future__ import annotations from collections.abc import Iterator +from typing import cast from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -19,7 +20,6 @@ from sqlalchemy.orm import Session from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginVerification from core.plugin.plugin_service import PluginService -from enums.deployment_edition import DeploymentEdition from models import ProviderType from models.engine import db from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider @@ -27,20 +27,16 @@ from services.errors.plugin import PluginInstallationForbiddenError from services.feature_service import ( PluginInstallationPermissionModel, PluginInstallationScope, - SystemFeatureModel, ) -def _make_features( +def _make_permission( restrict_to_marketplace: bool = False, scope: PluginInstallationScope = PluginInstallationScope.ALL, -) -> SystemFeatureModel: - return SystemFeatureModel( - deployment_edition=DeploymentEdition.COMMUNITY, - plugin_installation_permission=PluginInstallationPermissionModel( - restrict_to_marketplace_only=restrict_to_marketplace, - plugin_installation_scope=scope, - ), +) -> PluginInstallationPermissionModel: + return PluginInstallationPermissionModel( + restrict_to_marketplace_only=restrict_to_marketplace, + plugin_installation_scope=scope, ) @@ -119,22 +115,31 @@ class TestFetchLatestPluginVersion: class TestCheckMarketplaceOnlyPermission: @patch("core.plugin.plugin_service.FeatureService") def test_raises_when_restricted(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=True) with pytest.raises(PluginInstallationForbiddenError): PluginService._check_marketplace_only_permission() @patch("core.plugin.plugin_service.FeatureService") def test_passes_when_not_restricted(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(restrict_to_marketplace=False) PluginService._check_marketplace_only_permission() # should not raise + @patch("core.plugin.plugin_service.FeatureService") + def test_raises_when_scope_denies_all(self, mock_fs): + mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE) + + with pytest.raises(PluginInstallationForbiddenError, match="not allowed"): + PluginService._check_marketplace_only_permission() + class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_official_only_allows_langgenius(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY) + mock_fs.get_plugin_installation_permission.return_value = _make_permission( + scope=PluginInstallationScope.OFFICIAL_ONLY + ) verification = MagicMock() verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius @@ -142,14 +147,16 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_official_only_rejects_third_party(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY) + mock_fs.get_plugin_installation_permission.return_value = _make_permission( + scope=PluginInstallationScope.OFFICIAL_ONLY + ) with pytest.raises(PluginInstallationForbiddenError): PluginService._check_plugin_installation_scope(None) @patch("core.plugin.plugin_service.FeatureService") def test_official_and_partners_allows_partner(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features( + mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS ) verification = MagicMock() @@ -159,7 +166,7 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_official_and_partners_rejects_none(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features( + mock_fs.get_plugin_installation_permission.return_value = _make_permission( scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS ) @@ -168,7 +175,7 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_none_scope_always_raises(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.NONE) verification = MagicMock() verification.authorized_category = PluginVerification.AuthorizedCategory.Langgenius @@ -177,10 +184,19 @@ class TestCheckPluginInstallationScope: @patch("core.plugin.plugin_service.FeatureService") def test_all_scope_passes_any(self, mock_fs): - mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL) + mock_fs.get_plugin_installation_permission.return_value = _make_permission(scope=PluginInstallationScope.ALL) PluginService._check_plugin_installation_scope(None) # should not raise + @patch("core.plugin.plugin_service.FeatureService") + def test_unknown_scope_always_raises(self, mock_fs): + permission = _make_permission() + permission.plugin_installation_scope = cast(PluginInstallationScope, "unknown-scope") + mock_fs.get_plugin_installation_permission.return_value = permission + + with pytest.raises(PluginInstallationForbiddenError, match="policy is invalid"): + PluginService._check_plugin_installation_scope(None) + class TestGetPluginIconUrl: @patch("core.plugin.plugin_service.dify_config") @@ -248,7 +264,7 @@ class TestUpgradePluginWithMarketplace: @patch("core.plugin.plugin_service.dify_config") def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.return_value = MagicMock() installer.upgrade_plugin.return_value = MagicMock() @@ -264,7 +280,7 @@ class TestUpgradePluginWithMarketplace: @patch("core.plugin.plugin_service.dify_config") def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.side_effect = RuntimeError("not found") mock_download.return_value = b"pkg-bytes" @@ -283,7 +299,7 @@ class TestUpgradePluginWithGithub: @patch("core.plugin.plugin_service.FeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.upgrade_plugin.return_value = MagicMock() @@ -298,7 +314,7 @@ class TestUploadPkg: @patch("core.plugin.plugin_service.FeatureService") @patch("core.plugin.plugin_service.PluginInstaller") def test_runs_permission_and_scope_checks(self, mock_installer_cls: MagicMock, mock_fs: MagicMock): - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() upload_resp = MagicMock() upload_resp.verification = None mock_installer_cls.return_value.upload_pkg.return_value = upload_resp @@ -322,7 +338,7 @@ class TestInstallFromMarketplacePkg: @patch("core.plugin.plugin_service.dify_config") def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.side_effect = RuntimeError("not found") mock_download.return_value = b"pkg" @@ -344,7 +360,7 @@ class TestInstallFromMarketplacePkg: @patch("core.plugin.plugin_service.dify_config") def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls: MagicMock, mock_fs: MagicMock): mock_config.MARKETPLACE_ENABLED = True - mock_fs.get_system_features.return_value = _make_features() + mock_fs.get_plugin_installation_permission.return_value = _make_permission() installer = mock_installer_cls.return_value installer.fetch_plugin_manifest.return_value = MagicMock() decode_resp = MagicMock() diff --git a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py new file mode 100644 index 00000000000..a1ab95095f6 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py @@ -0,0 +1,92 @@ +import logging + +import pytest + +from enums.deployment_edition import DeploymentEdition +from services import feature_service as feature_service_module +from services.feature_service import FeatureService, PluginInstallationScope, SystemFeatureModel + + +def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False) + + permission = FeatureService.get_plugin_installation_permission() + + assert permission.plugin_installation_scope is PluginInstallationScope.ALL + assert permission.restrict_to_marketplace_only is False + + +def test_get_plugin_installation_permission_parses_enterprise_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod( + lambda: { + "PluginInstallationPermission": { + "pluginInstallationScope": "official_only", + "restrictToMarketplaceOnly": True, + } + } + ), + ) + + permission = FeatureService.get_plugin_installation_permission() + + assert permission.plugin_installation_scope is PluginInstallationScope.OFFICIAL_ONLY + assert permission.restrict_to_marketplace_only is True + + +@pytest.mark.parametrize( + "invalid_permission", + [ + { + "pluginInstallationScope": "unknown-scope", + "restrictToMarketplaceOnly": False, + }, + { + "pluginInstallationScope": "all", + "restrictToMarketplaceOnly": "false", + }, + ], + ids=["unknown_scope", "non_boolean_marketplace_restriction"], +) +def test_invalid_enterprise_policy_denies_all_plugin_installations( + caplog: pytest.LogCaptureFixture, + invalid_permission: dict[str, object], +) -> None: + with caplog.at_level(logging.ERROR, logger="services.feature_service"): + permission = FeatureService._resolve_plugin_installation_permission( + {"PluginInstallationPermission": invalid_permission} + ) + + assert permission.plugin_installation_scope is PluginInstallationScope.NONE + assert permission.restrict_to_marketplace_only is True + assert "denying all plugin installations" in caplog.text + + +def test_system_features_exposes_only_validated_plugin_installation_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + feature_service_module.EnterpriseService, + "get_info", + staticmethod( + lambda: { + "PluginInstallationPermission": { + "pluginInstallationScope": "unknown-scope", + "restrictToMarketplaceOnly": False, + } + } + ), + ) + features = SystemFeatureModel(deployment_edition=DeploymentEdition.ENTERPRISE) + + FeatureService._fulfill_params_from_enterprise(features) + + assert features.plugin_installation_permission.plugin_installation_scope is PluginInstallationScope.NONE + assert features.plugin_installation_permission.restrict_to_marketplace_only is True diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts index c1c5418578b..2b0dd1e39b1 100644 --- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts +++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts @@ -4,125 +4,101 @@ import { InstallationScope } from '@/features/system-features/constants' import { renderHookWithConsoleQuery as renderHook } from '@/test/console/query-data' import { pluginInstallLimit } from '../use-install-plugin-limit' +type PluginInstallCandidate = Parameters[0] +type SystemFeatures = Parameters[1] + const basePlugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' }, +} satisfies PluginInstallCandidate + +function makeSystemFeatures( + scope: PluginInstallationScope, + restrictToMarketplaceOnly = false, +): SystemFeatures { + return { + plugin_installation_permission: { + restrict_to_marketplace_only: restrictToMarketplaceOnly, + plugin_installation_scope: scope, + }, + } } describe('pluginInstallLimit', () => { it('should allow all plugins when scope is ALL', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.ALL, - }, - } + const features = makeSystemFeatures(InstallationScope.ALL) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true) }) it('should deny all plugins when scope is NONE', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.NONE, - }, - } + const features = makeSystemFeatures(InstallationScope.NONE) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(false) }) it('should allow langgenius plugins when scope is OFFICIAL_ONLY', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, - }, - } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true) }) it('should deny non-official plugins when scope is OFFICIAL_ONLY', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, - }, - } - const plugin = { ...basePlugin, verification: { authorized_category: 'community' } } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY) + const plugin = { + ...basePlugin, + verification: { authorized_category: 'community' as const }, + } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(false) }) it('should allow partner plugins when scope is OFFICIAL_AND_PARTNER', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_AND_PARTNER, - }, - } - const plugin = { ...basePlugin, verification: { authorized_category: 'partner' } } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_AND_PARTNER) + const plugin = { + ...basePlugin, + verification: { authorized_category: 'partner' as const }, + } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(true) }) it('should deny github plugins when restrict_to_marketplace_only is true', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } - const plugin = { ...basePlugin, from: 'github' as const } + const features = makeSystemFeatures(InstallationScope.ALL, true) + const plugin = { ...basePlugin, from: 'github' as const } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(false) }) it('should deny package plugins when restrict_to_marketplace_only is true', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } - const plugin = { ...basePlugin, from: 'package' as const } + const features = makeSystemFeatures(InstallationScope.ALL, true) + const plugin = { ...basePlugin, from: 'package' as const } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(false) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(false) }) it('should allow marketplace plugins even when restrict_to_marketplace_only is true', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: true, - plugin_installation_scope: InstallationScope.ALL, - }, - } + const features = makeSystemFeatures(InstallationScope.ALL, true) - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(true) }) it('should default to langgenius when no verification info', () => { - const features = { - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.OFFICIAL_ONLY, - }, - } - const plugin = { from: 'marketplace' as const } + const features = makeSystemFeatures(InstallationScope.OFFICIAL_ONLY) + const plugin = { from: 'marketplace' as const } satisfies PluginInstallCandidate - expect(pluginInstallLimit(plugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(plugin, features).canInstall).toBe(true) }) - it('should fallback to canInstall true for unrecognized scope', () => { + it('should deny installation for an unrecognized runtime scope', () => { const features = { plugin_installation_permission: { restrict_to_marketplace_only: false, - plugin_installation_scope: 'unknown-scope' as unknown as PluginInstallationScope, + plugin_installation_scope: 'unknown-scope', }, - } + } as unknown as SystemFeatures - expect(pluginInstallLimit(basePlugin as never, features as never).canInstall).toBe(true) + expect(pluginInstallLimit(basePlugin, features).canInstall).toBe(false) }) }) @@ -132,9 +108,9 @@ describe('usePluginInstallLimit', () => { const plugin = { from: 'marketplace' as const, verification: { authorized_category: 'langgenius' }, - } + } satisfies PluginInstallCandidate - const { result } = renderHook(() => usePluginInstallLimit(plugin as never)) + const { result } = renderHook(() => usePluginInstallLimit(plugin)) expect(result.current.canInstall).toBe(true) }) diff --git a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx index 933f065d80e..7c498f00ecc 100644 --- a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx +++ b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx @@ -1,68 +1,55 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' -import type { Plugin, PluginManifestInMarket } from '../../types' +import type { + PluginBundleDependencyType, + PluginVerification, +} from '@dify/contracts/api/console/workspaces/types.gen' import { useSuspenseQuery } from '@tanstack/react-query' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { InstallationScope } from '@/features/system-features/constants' -type PluginProps = (Plugin | PluginManifestInMarket) & { - from: 'github' | 'marketplace' | 'package' +type PluginInstallCandidate = { + from: PluginBundleDependencyType + verification?: PluginVerification | null } type PluginInstallLimitResult = { canInstall: boolean } +function denyUnsupportedInstallationScope(_scope: never): PluginInstallLimitResult { + return { canInstall: false } +} + export function pluginInstallLimit( - plugin: PluginProps, + plugin: PluginInstallCandidate, systemFeatures: Pick, ) { - if (systemFeatures.plugin_installation_permission.restrict_to_marketplace_only) { + const permission = systemFeatures.plugin_installation_permission + if (permission.restrict_to_marketplace_only) { if (plugin.from === 'github' || plugin.from === 'package') return { canInstall: false } } - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.ALL - ) { - return { - canInstall: true, - } - } - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.NONE - ) { - return { - canInstall: false, - } - } - const verification = plugin.verification || {} - if (!plugin.verification || !plugin.verification.authorized_category) - verification.authorized_category = 'langgenius' + const authorizedCategory = plugin.verification?.authorized_category ?? 'langgenius' + const scope = permission.plugin_installation_scope - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.OFFICIAL_ONLY - ) { - return { - canInstall: verification.authorized_category === 'langgenius', - } - } - if ( - systemFeatures.plugin_installation_permission.plugin_installation_scope === - InstallationScope.OFFICIAL_AND_PARTNER - ) { - return { - canInstall: - verification.authorized_category === 'langgenius' || - verification.authorized_category === 'partner', - } - } - return { - canInstall: true, + switch (scope) { + case InstallationScope.ALL: + return { canInstall: true } + case InstallationScope.NONE: + return { canInstall: false } + case InstallationScope.OFFICIAL_ONLY: + return { canInstall: authorizedCategory === 'langgenius' } + case InstallationScope.OFFICIAL_AND_PARTNER: + return { + canInstall: authorizedCategory === 'langgenius' || authorizedCategory === 'partner', + } + default: + return denyUnsupportedInstallationScope(scope) } } -export default function usePluginInstallLimit(plugin: PluginProps): PluginInstallLimitResult { +export default function usePluginInstallLimit( + plugin: PluginInstallCandidate, +): PluginInstallLimitResult { const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return pluginInstallLimit(plugin, systemFeatures) From b2d54cb2e914b31c5eaca15636c36e83da11dc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Tue, 28 Jul 2026 13:15:35 +0800 Subject: [PATCH 081/531] chore: improve archived logs hint in logs list page (#39659) --- .../__tests__/archived-logs-notice.spec.tsx | 14 +- .../app/log/__tests__/filter.spec.tsx | 165 +++++++++++++++--- .../app/log/__tests__/index.spec.tsx | 156 ++++++++++++++--- .../retention-upgrade-notice.spec.tsx | 117 +++++++++++++ .../app/log/archived-logs-notice.tsx | 32 ++-- .../app/log/cloud-sandbox-retention.ts | 54 ++++++ web/app/components/app/log/filter.tsx | 22 ++- web/app/components/app/log/index.tsx | 26 ++- .../app/log/retention-upgrade-notice.tsx | 43 +++++ .../workflow-log/__tests__/filter.spec.tsx | 97 ++++++++++ .../app/workflow-log/__tests__/index.spec.tsx | 106 ++++++++++- .../components/app/workflow-log/filter.tsx | 22 ++- web/app/components/app/workflow-log/index.tsx | 30 +++- web/i18n/ar-TN/app-log.json | 1 + web/i18n/de-DE/app-log.json | 1 + web/i18n/en-US/app-log.json | 3 +- web/i18n/es-ES/app-log.json | 1 + web/i18n/fa-IR/app-log.json | 1 + web/i18n/fr-FR/app-log.json | 1 + web/i18n/hi-IN/app-log.json | 1 + web/i18n/id-ID/app-log.json | 1 + web/i18n/it-IT/app-log.json | 1 + web/i18n/ja-JP/app-log.json | 1 + web/i18n/ko-KR/app-log.json | 1 + web/i18n/nl-NL/app-log.json | 1 + web/i18n/pl-PL/app-log.json | 1 + web/i18n/pt-BR/app-log.json | 1 + web/i18n/ro-RO/app-log.json | 1 + web/i18n/ru-RU/app-log.json | 1 + web/i18n/sl-SI/app-log.json | 1 + web/i18n/th-TH/app-log.json | 1 + web/i18n/tr-TR/app-log.json | 1 + web/i18n/uk-UA/app-log.json | 1 + web/i18n/vi-VN/app-log.json | 1 + web/i18n/zh-Hans/app-log.json | 1 + web/i18n/zh-Hant/app-log.json | 1 + 36 files changed, 833 insertions(+), 76 deletions(-) create mode 100644 web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx create mode 100644 web/app/components/app/log/cloud-sandbox-retention.ts create mode 100644 web/app/components/app/log/retention-upgrade-notice.tsx diff --git a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx index e255b3b0954..9f7214d4ae4 100644 --- a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx +++ b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx @@ -1,4 +1,5 @@ -import { fireEvent, screen } from '@testing-library/react' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -67,11 +68,16 @@ describe('ArchivedLogsNotice', () => { ) }) - it('should show notice for paid workspace managers', () => { + it('should show an accessible notice for paid workspace managers', async () => { + const user = userEvent.setup() renderNotice() - expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' })) + const notice = screen.getByRole('status') + expect(notice).toHaveAttribute('aria-live', 'polite') + expect(notice).toHaveAttribute('aria-atomic', 'true') + expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument() + + await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' })) expect(setShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES, }) diff --git a/web/app/components/app/log/__tests__/filter.spec.tsx b/web/app/components/app/log/__tests__/filter.spec.tsx index ee580d2cc0a..45e9be675f7 100644 --- a/web/app/components/app/log/__tests__/filter.spec.tsx +++ b/web/app/components/app/log/__tests__/filter.spec.tsx @@ -1,9 +1,37 @@ import type { QueryParam } from '../index' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' import Filter, { TIME_PERIOD_MAPPING } from '../filter' let mockAnnotationsCountLoading = false let mockAnnotationsCountData: { count: number } | null = { count: 10 } +const mockRuntime = vi.hoisted(() => ({ + deploymentEdition: 'CLOUD', + enableBilling: true, + isFetchedPlan: true, + isFetchedPlanInfo: true, + planType: 'professional', +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }), + } +}) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: () => ({ + enableBilling: mockRuntime.enableBilling, + isFetchedPlan: mockRuntime.isFetchedPlan, + isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo, + plan: { type: mockRuntime.planType }, + }), + } +}) vi.mock('@/service/use-log', () => ({ useAnnotationsCount: () => ({ @@ -12,28 +40,43 @@ vi.mock('@/service/use-log', () => ({ }), })) -vi.mock('@/app/components/base/chip', () => ({ - default: ({ - items, - value, - onSelect, - onClear, - }: { - items: Array<{ value: string; name: string }> - value?: string - onSelect: (item: { value: string; name: string }) => void - onClear: () => void - }) => { - const currentItem = items.find((item) => item.value === value) ?? items[0] - return ( -
-
{currentItem?.name}
- - -
- ) - }, -})) +vi.mock('@/app/components/base/chip', async () => { + const { useState } = await import('react') + + return { + default: function MockChip({ + items, + value, + onSelect, + onClear, + }: { + items: Array<{ value: string; name: string }> + value?: string + onSelect: (item: { value: string; name: string }) => void + onClear: () => void + }) { + const [isOpen, setIsOpen] = useState(false) + const currentItem = items.find((item) => item.value === value) ?? items[0] + return ( +
+
{currentItem?.name}
+ + {isOpen && ( +
    + {items.map((item) => ( +
  • {item.name}
  • + ))} +
+ )} + + +
+ ) + }, + } +}) vi.mock('@/app/components/base/sort', () => ({ default: ({ onSelect }: { onSelect: (value: string) => void }) => ( @@ -59,6 +102,11 @@ describe('Filter', () => { vi.clearAllMocks() mockAnnotationsCountLoading = false mockAnnotationsCountData = { count: 10 } + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.enableBilling = true + mockRuntime.isFetchedPlan = true + mockRuntime.isFetchedPlanInfo = true + mockRuntime.planType = 'professional' }) describe('Rendering', () => { @@ -124,6 +172,77 @@ describe('Filter', () => { }) describe('User Interactions', () => { + it('should only show supported periods for Cloud sandbox workspaces', () => { + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/), + ]) + }) + + it('should only show supported periods while the Cloud plan is pending', () => { + mockRuntime.isFetchedPlan = false + mockRuntime.isFetchedPlanInfo = false + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/), + expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/), + ]) + }) + + it('should keep all periods when Cloud billing is known to be disabled', () => { + mockRuntime.enableBilling = false + mockRuntime.isFetchedPlan = false + mockRuntime.isFetchedPlanInfo = true + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem')).toHaveLength(9) + }) + + it('should keep all periods for sandbox workspaces outside Cloud', () => { + mockRuntime.deploymentEdition = 'COMMUNITY' + mockRuntime.planType = 'sandbox' + + render() + + fireEvent.click(screen.getByRole('button', { name: 'open-options-1' })) + + const periodOptions = within(screen.getByRole('list', { name: 'options-1' })) + expect(periodOptions.getAllByRole('listitem')).toHaveLength(9) + }) + + it('should reset the Cloud sandbox period to today when cleared', () => { + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render() + + fireEvent.click(screen.getAllByText('clear-chip')[0]!) + + expect(mockSetQueryParams).toHaveBeenCalledWith({ + ...defaultQueryParams, + period: '1', + }) + }) + it('should update keyword when typing in search input', () => { render() diff --git a/web/app/components/app/log/__tests__/index.spec.tsx b/web/app/components/app/log/__tests__/index.spec.tsx index 13614d652de..65330f27358 100644 --- a/web/app/components/app/log/__tests__/index.spec.tsx +++ b/web/app/components/app/log/__tests__/index.spec.tsx @@ -1,5 +1,8 @@ /* oxlint-disable typescript/no-explicit-any */ +import type { CloudSandboxPlanState } from '../cloud-sandbox-retention' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import dayjs from 'dayjs' import { APP_PAGE_LIMIT } from '@/config' import { AppModeEnum } from '@/types/app' import Logs from '../index' @@ -11,11 +14,27 @@ vi.mock('@/context/i18n', () => ({ const mockReplace = vi.fn() const mockUseChatConversations = vi.fn() const mockUseCompletionConversations = vi.fn() +const mockPlanState = vi.hoisted(() => ({ + value: 'unrestricted' as CloudSandboxPlanState, +})) +const mockDebouncedPeriod = vi.hoisted(() => ({ + value: null as string | null, +})) let mockSearchParams = new URLSearchParams() vi.mock('ahooks', async () => { return { - useDebounce: (value: T) => value, + useDebounce: (value: T) => { + if ( + mockDebouncedPeriod.value === null || + typeof value !== 'object' || + value === null || + !('period' in value) + ) + return value + + return { ...value, period: mockDebouncedPeriod.value } + }, } }) @@ -33,28 +52,19 @@ vi.mock('@/next/navigation', () => ({ vi.mock('@/service/use-log', () => ({ useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args), useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args), + useAnnotationsCount: () => ({ + data: { count: 0 }, + isLoading: false, + }), })) -vi.mock('../filter', () => ({ - TIME_PERIOD_MAPPING: { - 2: { value: 7 }, - 9: { value: 0 }, - }, - default: ({ setQueryParams }: { setQueryParams: (next: Record) => void }) => ( - - ), -})) +vi.mock('../cloud-sandbox-retention', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCloudSandboxPlanStatus: () => mockPlanState.value, + } +}) vi.mock('../list', () => ({ default: ({ logs }: { logs: { total?: number } }) => ( @@ -69,6 +79,10 @@ vi.mock('../empty-element', () => ({ default: () =>
empty-logs
, })) +vi.mock('../retention-upgrade-notice', () => ({ + RetentionUpgradeNotice: () =>
retention-upgrade-notice
, +})) + vi.mock('@/app/components/base/loading', () => ({ default: () =>
loading-logs
, })) @@ -85,6 +99,8 @@ describe('Logs', () => { beforeEach(() => { vi.clearAllMocks() mockSearchParams = new URLSearchParams() + mockPlanState.value = 'unrestricted' + mockDebouncedPeriod.value = null mockUseChatConversations.mockReturnValue({ data: undefined, refetch: vi.fn(), @@ -117,6 +133,7 @@ describe('Logs', () => { expect( screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ }), ).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs') + expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument() expect(screen.getByText('loading-logs')).toBeInTheDocument() }) @@ -166,4 +183,101 @@ describe('Logs', () => { expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false }) }) + + it('should query the last 30 days when a Sandbox user selects the longest period', async () => { + const user = userEvent.setup() + mockPlanState.value = 'sandbox' + mockUseChatConversations.mockReturnValue({ + data: { total: 0 }, + refetch: vi.fn(), + }) + + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ })) + await user.click(await screen.findByText(/appLog\.filter\.period\.last30days/)) + + expect( + screen.getByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }), + ).toBeInTheDocument() + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'), + end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), + }), + }), + ) + }) + + it('should use a valid period for the real Chip and request when a cached period settles to Sandbox', async () => { + const user = userEvent.setup() + const appDetail = { + id: 'app-period-transition', + mode: AppModeEnum.CHAT, + } as any + mockUseChatConversations.mockReturnValue({ + data: { total: 0 }, + refetch: vi.fn(), + }) + + const unrestrictedRender = render() + + await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ })) + await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/)) + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.not.objectContaining({ + start: expect.anything(), + end: expect.anything(), + }), + }), + ) + unrestrictedRender.unmount() + + mockPlanState.value = 'pending' + mockDebouncedPeriod.value = '9' + const pendingRender = render() + + expect( + screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /common\.operation\.clear appLog\.filter\.period\.today/, + }), + ).toBeInTheDocument() + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), + end: expect.any(String), + }), + }), + ) + + mockPlanState.value = 'sandbox' + pendingRender.rerender() + + expect( + screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }), + ).toBeInTheDocument() + expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual( + expect.objectContaining({ + params: expect.objectContaining({ + start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'), + end: expect.any(String), + }), + }), + ) + }) }) diff --git a/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx b/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx new file mode 100644 index 00000000000..13e72de8a16 --- /dev/null +++ b/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx @@ -0,0 +1,117 @@ +import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { defaultPlan } from '@/app/components/billing/config' +import { Plan } from '@/app/components/billing/type' +import { useModalContext } from '@/context/modal-context' +import { useProviderContext } from '@/context/provider-context' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { render } from '@/test/console/render' +import { RetentionUpgradeNotice } from '../retention-upgrade-notice' + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: vi.fn(), + } +}) + +vi.mock('@/context/modal-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useModalContext: vi.fn(), + } +}) + +const mockUseProviderContext = vi.mocked(useProviderContext) +const mockUseModalContext = vi.mocked(useModalContext) + +describe('RetentionUpgradeNotice', () => { + const setShowPricingModal = vi.fn() + + function mockProvider({ + enableBilling = true, + isFetchedPlan = true, + isFetchedPlanInfo = true, + planType = Plan.sandbox, + }: { + enableBilling?: boolean + isFetchedPlan?: boolean + isFetchedPlanInfo?: boolean + planType?: Plan + } = {}) { + mockUseProviderContext.mockReturnValue( + createMockProviderContextValue({ + enableBilling, + isFetchedPlan, + isFetchedPlanInfo, + plan: { + ...defaultPlan, + type: planType, + }, + }), + ) + } + + function renderNotice(deploymentEdition: DeploymentEdition = 'CLOUD') { + const { wrapper } = createConsoleQueryWrapper({ + systemFeatures: { deployment_edition: deploymentEdition }, + }) + return render(, { wrapper }) + } + + beforeEach(() => { + vi.clearAllMocks() + mockProvider() + mockUseModalContext.mockReturnValue({ + setShowPricingModal, + } as unknown as ReturnType) + }) + + it('should show accessible upgrade guidance for Cloud sandbox workspaces', async () => { + const user = userEvent.setup() + renderNotice() + + const notice = screen.getByRole('status') + expect(notice).toHaveAttribute('aria-live', 'polite') + expect(notice).toHaveAttribute('aria-atomic', 'true') + expect(within(notice).getByText('appLog.retention.upgradeTip.description')).toBeInTheDocument() + + await user.click( + within(notice).getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }), + ) + expect(setShowPricingModal).toHaveBeenCalledOnce() + }) + + it.each([ + { + name: 'paid Cloud workspaces', + provider: { planType: Plan.professional }, + deploymentEdition: 'CLOUD', + }, + { + name: 'self-hosted sandbox workspaces', + provider: { planType: Plan.sandbox }, + deploymentEdition: 'COMMUNITY', + }, + { + name: 'workspaces without billing', + provider: { enableBilling: false }, + deploymentEdition: 'CLOUD', + }, + { + name: 'workspaces before plan loading completes', + provider: { isFetchedPlan: false, isFetchedPlanInfo: false }, + deploymentEdition: 'CLOUD', + }, + ] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => { + mockProvider(provider) + + renderNotice(deploymentEdition) + + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/log/archived-logs-notice.tsx b/web/app/components/app/log/archived-logs-notice.tsx index fb372854397..ef3f59f1c72 100644 --- a/web/app/components/app/log/archived-logs-notice.tsx +++ b/web/app/components/app/log/archived-logs-notice.tsx @@ -1,5 +1,6 @@ 'use client' +import { Button } from '@langgenius/dify-ui/button' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' @@ -31,16 +32,27 @@ export function ArchivedLogsNotice() { return null return ( -
- + ) diff --git a/web/app/components/app/log/cloud-sandbox-retention.ts b/web/app/components/app/log/cloud-sandbox-retention.ts new file mode 100644 index 00000000000..c7f7db14fc1 --- /dev/null +++ b/web/app/components/app/log/cloud-sandbox-retention.ts @@ -0,0 +1,54 @@ +'use client' + +import { useSuspenseQuery } from '@tanstack/react-query' +import { Plan } from '@/app/components/billing/type' +import { useProviderContext } from '@/context/provider-context' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' + +export const CLOUD_SANDBOX_TIME_PERIOD_KEYS = new Set(['1', '2', '3']) +export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1' + +const CLOUD_SANDBOX_LONGEST_TIME_PERIOD = '3' +const CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION = { + value: 30, + name: 'last30days', +} as const + +export type CloudSandboxPlanState = 'pending' | 'sandbox' | 'unrestricted' + +export function isLogTimePeriodRestricted(planState: CloudSandboxPlanState) { + return planState !== 'unrestricted' +} + +export function resolveLogTimePeriod(period: string, planState: CloudSandboxPlanState) { + if (!isLogTimePeriodRestricted(planState) || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(period)) + return period + + return CLOUD_SANDBOX_CLEARED_TIME_PERIOD +} + +export function resolveLogTimePeriodOption( + period: string, + option: T, + planState: CloudSandboxPlanState, +) { + if (isLogTimePeriodRestricted(planState) && period === CLOUD_SANDBOX_LONGEST_TIME_PERIOD) + return CLOUD_SANDBOX_LONGEST_TIME_PERIOD_OPTION + + return option +} + +export function useCloudSandboxPlanStatus(): CloudSandboxPlanState { + const { data: deploymentEdition } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: ({ deployment_edition }) => deployment_edition, + }) + const { enableBilling, isFetchedPlan, isFetchedPlanInfo, plan } = useProviderContext() + + if (deploymentEdition !== 'CLOUD') return 'unrestricted' + if (!isFetchedPlanInfo) return 'pending' + if (!enableBilling) return 'unrestricted' + if (!isFetchedPlan) return 'pending' + + return plan.type === Plan.sandbox ? 'sandbox' : 'unrestricted' +} diff --git a/web/app/components/app/log/filter.tsx b/web/app/components/app/log/filter.tsx index 27c22ed13bf..b8e6781d51c 100644 --- a/web/app/components/app/log/filter.tsx +++ b/web/app/components/app/log/filter.tsx @@ -11,6 +11,13 @@ import Chip from '@/app/components/base/chip' import Input from '@/app/components/base/input' import Sort from '@/app/components/base/sort' import { useAnnotationsCount } from '@/service/use-log' +import { + CLOUD_SANDBOX_CLEARED_TIME_PERIOD, + CLOUD_SANDBOX_TIME_PERIOD_KEYS, + isLogTimePeriodRestricted, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from './cloud-sandbox-retention' dayjs.extend(quarterOfYear) @@ -45,6 +52,12 @@ const Filter: FC = ({ }: IFilterProps) => { const { data, isLoading } = useAnnotationsCount(appId) const { t } = useTranslation() + const planState = useCloudSandboxPlanStatus() + const isTimePeriodRestricted = isLogTimePeriodRestricted(planState) + const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING) + .filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key)) + .map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const) + if (isLoading || !data) return null return (
@@ -56,8 +69,13 @@ const Filter: FC = ({ onSelect={(item) => { setQueryParams({ ...queryParams, period: item.value }) }} - onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ + onClear={() => + setQueryParams({ + ...queryParams, + period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9', + }) + } + items={timePeriodEntries.map(([k, v]) => ({ value: k, name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }), }))} diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index 28b56078678..0c91182754d 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -15,9 +15,15 @@ import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useChatConversations, useCompletionConversations } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import PageTitle from '../log-annotation/page-title' +import { + resolveLogTimePeriod, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from './cloud-sandbox-retention' import EmptyElement from './empty-element' import Filter, { TIME_PERIOD_MAPPING } from './filter' import List from './list' +import { RetentionUpgradeNotice } from './retention-upgrade-notice' type ILogsProps = { appDetail: App @@ -57,6 +63,7 @@ const Logs: FC = ({ appDetail }) => { return pageParam - 1 }, [searchParams]) const cachedState = logsStateCache.get(appDetail.id) + const cloudSandboxPlanState = useCloudSandboxPlanStatus() const [queryParams, setQueryParams] = useState( cachedState?.queryParams ?? defaultQueryParams, ) @@ -64,7 +71,15 @@ const Logs: FC = ({ appDetail }) => { () => cachedState?.currPage ?? getPageFromParams(), ) const [limit, setLimit] = React.useState(cachedState?.limit ?? APP_PAGE_LIMIT) + const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState) + const effectiveQueryParams = { ...queryParams, period: effectivePeriod } const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) + const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod } + const requestTimePeriod = resolveLogTimePeriodOption( + requestQueryParams.period, + TIME_PERIOD_MAPPING[requestQueryParams.period]!, + cloudSandboxPlanState, + ) useEffect(() => { const pageFromParams = getPageFromParams() @@ -85,17 +100,17 @@ const Logs: FC = ({ appDetail }) => { const query = { page: currPage + 1, limit, - ...(debouncedQueryParams.period !== '9' + ...(requestQueryParams.period !== '9' ? { start: dayjs() - .subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day') + .subtract(requestTimePeriod.value, 'day') .startOf('day') .format('YYYY-MM-DD HH:mm'), end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'), } : {}), - ...(isChatMode ? { sort_by: debouncedQueryParams.sort_by } : {}), - ...omit(debouncedQueryParams, ['period']), + ...(isChatMode ? { sort_by: requestQueryParams.sort_by } : {}), + ...omit(requestQueryParams, ['period']), } // When the details are obtained, proceed to the next request @@ -143,9 +158,10 @@ const Logs: FC = ({ appDetail }) => { + {total === undefined ? ( ) : total > 0 ? ( diff --git a/web/app/components/app/log/retention-upgrade-notice.tsx b/web/app/components/app/log/retention-upgrade-notice.tsx new file mode 100644 index 00000000000..c738465c0f4 --- /dev/null +++ b/web/app/components/app/log/retention-upgrade-notice.tsx @@ -0,0 +1,43 @@ +'use client' + +import { useTranslation } from 'react-i18next' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import { useCloudSandboxPlanStatus } from './cloud-sandbox-retention' + +export function RetentionUpgradeNotice() { + const { t } = useTranslation() + const planState = useCloudSandboxPlanStatus() + + if (planState !== 'sandbox') return null + + return ( +
+ + ) +} diff --git a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx index 547354903f2..476c5dbce98 100644 --- a/web/app/components/app/workflow-log/__tests__/filter.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/filter.spec.tsx @@ -17,6 +17,35 @@ import Filter, { TIME_PERIOD_MAPPING } from '../filter' // Mocks // ============================================================================ +const mockRuntime = vi.hoisted(() => ({ + deploymentEdition: 'CLOUD', + enableBilling: true, + isFetchedPlan: true, + isFetchedPlanInfo: true, + planType: 'professional', +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }), + } +}) + +vi.mock('@/context/provider-context', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useProviderContext: () => ({ + enableBilling: mockRuntime.enableBilling, + isFetchedPlan: mockRuntime.isFetchedPlan, + isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo, + plan: { type: mockRuntime.planType }, + }), + } +}) + const mockTrackEvent = vi.fn() vi.mock('@/app/components/base/amplitude/utils', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), @@ -41,6 +70,11 @@ describe('Filter', () => { beforeEach(() => { vi.clearAllMocks() + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.enableBilling = true + mockRuntime.isFetchedPlan = true + mockRuntime.isFetchedPlanInfo = true + mockRuntime.planType = 'professional' }) // -------------------------------------------------------------------------- @@ -176,6 +210,69 @@ describe('Filter', () => { // Time Period Filter Tests // -------------------------------------------------------------------------- describe('Time Period Filter', () => { + it('should only show supported periods for Cloud sandbox workspaces', async () => { + const user = userEvent.setup() + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })) + + const listbox = await screen.findByRole('listbox') + expect( + within(listbox) + .getAllByRole('option') + .map((option) => option.textContent), + ).toEqual([ + 'appLog.filter.period.today', + 'appLog.filter.period.last7days', + 'appLog.filter.period.last30days', + ]) + }) + + it('should keep all periods for sandbox workspaces outside Cloud', async () => { + const user = userEvent.setup() + mockRuntime.deploymentEdition = 'COMMUNITY' + mockRuntime.planType = 'sandbox' + + render( + , + ) + + await user.click(screen.getByRole('combobox', { name: 'appLog.filter.period.last7days' })) + + const listbox = await screen.findByRole('listbox') + expect(within(listbox).getAllByRole('option')).toHaveLength(9) + }) + + it('should reset the Cloud sandbox period to today when cleared', async () => { + const user = userEvent.setup() + const setQueryParams = vi.fn() + mockRuntime.deploymentEdition = 'CLOUD' + mockRuntime.planType = 'sandbox' + + render( + , + ) + + await user.click( + screen.getByRole('button', { + name: /common\.operation\.clear appLog\.filter\.period\.last30days/, + }), + ) + + expect(setQueryParams).toHaveBeenCalledWith({ + status: 'all', + period: '1', + }) + }) + it('should display current period value', () => { render( ({ + value: 'unrestricted' as CloudSandboxPlanState, +})) +const mockDebouncedPeriod = vi.hoisted(() => ({ + value: null as string | null, +})) + vi.mock('@/service/use-log') +vi.mock('../../log/cloud-sandbox-retention', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCloudSandboxPlanStatus: () => mockPlanState.value, + } +}) + vi.mock('ahooks', () => ({ - useDebounce: (value: T) => value, + useDebounce: (value: T) => { + if ( + mockDebouncedPeriod.value === null || + typeof value !== 'object' || + value === null || + !('period' in value) + ) + return value + + return { ...value, period: mockDebouncedPeriod.value } + }, useDebounceFn: (fn: (value: string) => void) => ({ run: fn }), useBoolean: (initial: boolean) => { const setters = { @@ -58,6 +85,10 @@ vi.mock('@/next/link', () => ({ ), })) +vi.mock('../../log/retention-upgrade-notice', () => ({ + RetentionUpgradeNotice: () =>
retention-upgrade-notice
, +})) + // Mock the Run component to avoid complex dependencies vi.mock('@/app/components/workflow/run', () => ({ default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string; tracingListUrl: string }) => ( @@ -237,6 +268,8 @@ describe('Logs Container', () => { beforeEach(() => { vi.clearAllMocks() + mockPlanState.value = 'unrestricted' + mockDebouncedPeriod.value = null }) // -------------------------------------------------------------------------- @@ -272,6 +305,7 @@ describe('Logs Container', () => { // Assert expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument() + expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument() }) }) @@ -444,6 +478,76 @@ describe('Logs Container', () => { }) }) + it('should query the last 30 days when a Sandbox user selects the longest period', async () => { + const user = userEvent.setup() + mockPlanState.value = 'sandbox' + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) + + renderWithQueryClient() + + await user.click(screen.getByText('appLog.filter.period.last7days')) + await user.click(await screen.findByText('appLog.filter.period.last30days')) + + expect( + screen.getByRole('combobox', { name: 'appLog.filter.period.last30days' }), + ).toBeInTheDocument() + const params = getMockCallParams()?.params + expect( + dayjs(String(params?.created_at__before)).diff(String(params?.created_at__after), 'day'), + ).toBe(30) + }) + + it('should use a valid period for the real Chip and request when plan state settles to Sandbox', async () => { + const user = userEvent.setup() + mockedUseWorkflowLogs.mockReturnValue( + createMockQueryResult({ + data: createMockLogsResponse([], 0), + }), + ) + const rendered = renderWithQueryClient() + + await user.click(screen.getByText('appLog.filter.period.last7days')) + await user.click(await screen.findByText('appLog.filter.period.allTime')) + expect(getMockCallParams()?.params).not.toHaveProperty('created_at__after') + expect(getMockCallParams()?.params).not.toHaveProperty('created_at__before') + + mockPlanState.value = 'pending' + mockDebouncedPeriod.value = '9' + rendered.rerender() + + expect( + screen.getByRole('combobox', { name: 'appLog.filter.period.today' }), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: /common\.operation\.clear appLog\.filter\.period\.today/, + }), + ).toBeInTheDocument() + expect(getMockCallParams()?.params).toEqual( + expect.objectContaining({ + created_at__after: expect.any(String), + created_at__before: expect.any(String), + }), + ) + + mockPlanState.value = 'sandbox' + rendered.rerender() + + expect( + screen.getByRole('combobox', { name: 'appLog.filter.period.today' }), + ).toBeInTheDocument() + expect(getMockCallParams()?.params).toEqual( + expect.objectContaining({ + created_at__after: expect.any(String), + created_at__before: expect.any(String), + }), + ) + }) + it('should update query when typing keyword', async () => { // Arrange const user = userEvent.setup() diff --git a/web/app/components/app/workflow-log/filter.tsx b/web/app/components/app/workflow-log/filter.tsx index 1c1b555377e..2e09ce9c48a 100644 --- a/web/app/components/app/workflow-log/filter.tsx +++ b/web/app/components/app/workflow-log/filter.tsx @@ -10,6 +10,13 @@ import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude/utils' import Chip from '@/app/components/base/chip' import Input from '@/app/components/base/input' +import { + CLOUD_SANDBOX_CLEARED_TIME_PERIOD, + CLOUD_SANDBOX_TIME_PERIOD_KEYS, + isLogTimePeriodRestricted, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from '../log/cloud-sandbox-retention' dayjs.extend(quarterOfYear) @@ -36,6 +43,12 @@ type IFilterProps = { const Filter: FC = ({ queryParams, setQueryParams }: IFilterProps) => { const { t } = useTranslation() + const planState = useCloudSandboxPlanStatus() + const isTimePeriodRestricted = isLogTimePeriodRestricted(planState) + const timePeriodEntries = Object.entries(TIME_PERIOD_MAPPING) + .filter(([key]) => !isTimePeriodRestricted || CLOUD_SANDBOX_TIME_PERIOD_KEYS.has(key)) + .map(([key, option]) => [key, resolveLogTimePeriodOption(key, option, planState)] as const) + return (
= ({ queryParams, setQueryParams }: IFilterProps) onSelect={(item) => { setQueryParams({ ...queryParams, period: item.value }) }} - onClear={() => setQueryParams({ ...queryParams, period: '9' })} - items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ + onClear={() => + setQueryParams({ + ...queryParams, + period: isTimePeriodRestricted ? CLOUD_SANDBOX_CLEARED_TIME_PERIOD : '9', + }) + } + items={timePeriodEntries.map(([k, v]) => ({ value: k, name: t(($) => $[`filter.period.${v.name}`], { ns: 'appLog' }), }))} diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 24eb42eed45..7ec0bfb46dc 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -19,6 +19,12 @@ import { useWorkflowLogs } from '@/service/use-log' import PageTitle from '../log-annotation/page-title' import { ArchivedLogsNotice } from '../log/archived-logs-notice' import { shouldShowArchivedLogsNotice } from '../log/archived-logs-notice-utils' +import { + resolveLogTimePeriod, + resolveLogTimePeriodOption, + useCloudSandboxPlanStatus, +} from '../log/cloud-sandbox-retention' +import { RetentionUpgradeNotice } from '../log/retention-upgrade-notice' import Filter, { TIME_PERIOD_MAPPING } from './filter' import List from './list' @@ -43,26 +49,35 @@ const Logs: FC = ({ appDetail }) => { }) const [queryParams, setQueryParams] = useState({ status: 'all', period: '2' }) const [currPage, setCurrPage] = React.useState(0) + const cloudSandboxPlanState = useCloudSandboxPlanStatus() + const effectivePeriod = resolveLogTimePeriod(queryParams.period, cloudSandboxPlanState) + const effectiveQueryParams = { ...queryParams, period: effectivePeriod } const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) + const requestQueryParams = { ...debouncedQueryParams, period: effectivePeriod } + const requestTimePeriod = resolveLogTimePeriodOption( + requestQueryParams.period, + TIME_PERIOD_MAPPING[requestQueryParams.period]!, + cloudSandboxPlanState, + ) const [limit, setLimit] = React.useState(APP_PAGE_LIMIT) const query = { page: currPage + 1, detail: true, limit, - ...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}), - ...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}), - ...(debouncedQueryParams.period !== '9' + ...(requestQueryParams.status !== 'all' ? { status: requestQueryParams.status } : {}), + ...(requestQueryParams.keyword ? { keyword: requestQueryParams.keyword } : {}), + ...(requestQueryParams.period !== '9' ? { created_at__after: dayjs() - .subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period]!.value, 'day') + .subtract(requestTimePeriod.value, 'day') .startOf('day') .tz(timezone) .format('YYYY-MM-DDTHH:mm:ssZ'), created_at__before: dayjs().endOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'), } : {}), - ...omit(debouncedQueryParams, ['period', 'status']), + ...omit(requestQueryParams, ['period', 'status']), } const { data: workflowLogs, refetch: mutate } = useWorkflowLogs({ @@ -72,7 +87,7 @@ const Logs: FC = ({ appDetail }) => { const total = workflowLogs?.total const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1 const showArchivedLogsNotice = shouldShowArchivedLogsNotice( - queryParams.period, + effectiveQueryParams.period, TIME_PERIOD_MAPPING, ) @@ -83,7 +98,8 @@ const Logs: FC = ({ appDetail }) => { description={t(($) => $.workflowSubtitle, { ns: 'appLog' })} />
- + + {showArchivedLogsNotice && } {/* workflow log */} {total === undefined ? ( diff --git a/web/i18n/ar-TN/app-log.json b/web/i18n/ar-TN/app-log.json index 4eb315694bf..d8cf13af28e 100644 --- a/web/i18n/ar-TN/app-log.json +++ b/web/i18n/ar-TN/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "السنة حتى الآن", "filter.sortBy": "رتب حسب:", "monitoring.description": "يسجل الرصد حالة تشغيل التطبيق، بما في ذلك الأداء ونشاط المستخدمين والتكاليف.", + "retention.upgradeTip.description": "قم بالترقية للاحتفاظ بجميع السجلات التي يتم إنشاؤها بعد الترقية دون حد زمني؛ لا يمكن استعادة السجلات التي انتهت مدة الاحتفاظ بها قبل الترقية.", "runDetail.fileListDetail": "تفاصيل", "runDetail.fileListLabel": "تفاصيل الملف", "runDetail.testWithParams": "اختبار مع المعلمات", diff --git a/web/i18n/de-DE/app-log.json b/web/i18n/de-DE/app-log.json index d85a9d89ccd..8c2e5adbdc2 100644 --- a/web/i18n/de-DE/app-log.json +++ b/web/i18n/de-DE/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Jahr bis heute", "filter.sortBy": "Sortieren nach:", "monitoring.description": "Das Monitoring zeichnet den Betriebsstatus der Anwendung auf, einschließlich Leistung, Nutzeraktivität und Kosten.", + "retention.upgradeTip.description": "Führen Sie ein Upgrade Ihres Plans durch, um alle danach erstellten Protokolle unbegrenzt aufzubewahren. Protokolle, deren Aufbewahrungsfrist vor dem Upgrade abgelaufen ist, können nicht wiederhergestellt werden.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "Details zur Datei", "runDetail.testWithParams": "Test mit Parametern", diff --git a/web/i18n/en-US/app-log.json b/web/i18n/en-US/app-log.json index 9bc35200e3c..13a246805af 100644 --- a/web/i18n/en-US/app-log.json +++ b/web/i18n/en-US/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "No archived logs", "archives.error.description": "Refresh the page or try again later.", "archives.error.title": "Could not load archived logs", - "archives.notice.action": "View archived logs", + "archives.notice.action": "Open archived logs", "archives.notice.description": "Some logs in this time range may have been archived.", "archives.summary.latest": "Latest archive", "archives.summary.months": "Archived months", @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Year to date", "filter.sortBy": "Sort by:", "monitoring.description": "Monitoring records the running status of the application, including performance, user activity, and costs.", + "retention.upgradeTip.description": "Upgrade to retain all logs generated after upgrading with no time limit; previously expired logs can’t be recovered.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "File Details", "runDetail.testWithParams": "Test With Params", diff --git a/web/i18n/es-ES/app-log.json b/web/i18n/es-ES/app-log.json index 195382d5f8d..1a6abe0e6b4 100644 --- a/web/i18n/es-ES/app-log.json +++ b/web/i18n/es-ES/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Año hasta la fecha", "filter.sortBy": "Ordenar por:", "monitoring.description": "La monitorización registra el estado de ejecución de la aplicación, incluyendo rendimiento, actividad de los usuarios y costes.", + "retention.upgradeTip.description": "Mejora tu plan para conservar indefinidamente todos los registros que se generen después de la mejora. Los registros que hayan caducado antes de la mejora no se pueden recuperar.", "runDetail.fileListDetail": "Detalle", "runDetail.fileListLabel": "Detalles del archivo", "runDetail.testWithParams": "Prueba con parámetros", diff --git a/web/i18n/fa-IR/app-log.json b/web/i18n/fa-IR/app-log.json index fb93f738729..1e7a8db1b22 100644 --- a/web/i18n/fa-IR/app-log.json +++ b/web/i18n/fa-IR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "از ابتدای سال تاکنون", "filter.sortBy": "مرتب‌سازی بر اساس:", "monitoring.description": "مانیتورینگ وضعیت اجرای برنامه را ثبت می‌کند، از جمله عملکرد، فعالیت کاربران و هزینه‌ها.", + "retention.upgradeTip.description": "طرح خود را ارتقا دهید تا همه لاگ‌های ایجادشده پس از ارتقا بدون محدودیت زمانی نگهداری شوند؛ لاگ‌هایی که پیش از ارتقا منقضی شده‌اند قابل بازیابی نیستند.", "runDetail.fileListDetail": "جزئیات", "runDetail.fileListLabel": "جزئیات فایل", "runDetail.testWithParams": "تست با پارامترها", diff --git a/web/i18n/fr-FR/app-log.json b/web/i18n/fr-FR/app-log.json index 5ef8cb5ff3e..51bc30fd933 100644 --- a/web/i18n/fr-FR/app-log.json +++ b/web/i18n/fr-FR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Année à ce jour", "filter.sortBy": "Trier par :", "monitoring.description": "Le monitoring enregistre l’état de fonctionnement de l’application, notamment les performances, l’activité des utilisateurs et les coûts.", + "retention.upgradeTip.description": "Passez à une offre supérieure pour conserver sans limite de durée tous les journaux générés après la mise à niveau. Les journaux dont la durée de conservation a expiré avant la mise à niveau ne peuvent pas être récupérés.", "runDetail.fileListDetail": "Détail", "runDetail.fileListLabel": "Détails du fichier", "runDetail.testWithParams": "Test avec paramètres", diff --git a/web/i18n/hi-IN/app-log.json b/web/i18n/hi-IN/app-log.json index a78bc82a474..47adae71c94 100644 --- a/web/i18n/hi-IN/app-log.json +++ b/web/i18n/hi-IN/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "वर्ष तक तिथि", "filter.sortBy": "इसके अनुसार क्रमबद्ध करें:", "monitoring.description": "मॉनिटरिंग एप्लिकेशन की रनिंग स्थिति रिकॉर्ड करती है, जिसमें प्रदर्शन, उपयोगकर्ता गतिविधि और लागतें शामिल हैं।", + "retention.upgradeTip.description": "अपग्रेड करें और उसके बाद जनरेट किए गए सभी लॉग बिना किसी समय सीमा के सुरक्षित रखें; अपग्रेड से पहले समाप्त हो चुके लॉग पुनर्प्राप्त नहीं किए जा सकते।", "runDetail.fileListDetail": "विस्तार", "runDetail.fileListLabel": "फ़ाइल विवरण", "runDetail.testWithParams": "पैरामीटर्स के साथ परीक्षण", diff --git a/web/i18n/id-ID/app-log.json b/web/i18n/id-ID/app-log.json index 6e784ddc5e1..c0aff59edca 100644 --- a/web/i18n/id-ID/app-log.json +++ b/web/i18n/id-ID/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Tahun hingga saat ini", "filter.sortBy": "Urutkan berdasarkan", "monitoring.description": "Monitoring mencatat status berjalan aplikasi, termasuk performa, aktivitas pengguna, dan biaya.", + "retention.upgradeTip.description": "Tingkatkan paket untuk menyimpan semua log yang dibuat setelah upgrade tanpa batas waktu; log yang kedaluwarsa sebelum upgrade tidak dapat dipulihkan.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "Rincian File", "runDetail.testWithParams": "Uji Dengan Param", diff --git a/web/i18n/it-IT/app-log.json b/web/i18n/it-IT/app-log.json index f735e2efc6a..d2e7b497a05 100644 --- a/web/i18n/it-IT/app-log.json +++ b/web/i18n/it-IT/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Anno corrente", "filter.sortBy": "Ordina per:", "monitoring.description": "Il monitoraggio registra lo stato di esecuzione dell’applicazione, inclusi prestazioni, attività degli utenti e costi.", + "retention.upgradeTip.description": "Passa a un piano superiore per conservare senza limiti di tempo tutti i log generati dopo l'upgrade. I log scaduti prima dell'upgrade non possono essere recuperati.", "runDetail.fileListDetail": "Dettaglio", "runDetail.fileListLabel": "Dettagli del file", "runDetail.testWithParams": "Test con parametri", diff --git a/web/i18n/ja-JP/app-log.json b/web/i18n/ja-JP/app-log.json index f692843ef4b..25b49eeea13 100644 --- a/web/i18n/ja-JP/app-log.json +++ b/web/i18n/ja-JP/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "年初から今日まで", "filter.sortBy": "並べ替え", "monitoring.description": "モニタリングは、パフォーマンス、ユーザー活動、コストを含むアプリケーションの実行状況を記録します。", + "retention.upgradeTip.description": "アップグレード後に生成されたログを無期限に保存できます。すでに期限切れとなったログは復元できません。", "runDetail.fileListDetail": "詳細", "runDetail.fileListLabel": "ファイルの詳細", "runDetail.testWithParams": "パラメータ付きテスト", diff --git a/web/i18n/ko-KR/app-log.json b/web/i18n/ko-KR/app-log.json index 49c01088249..9a3f8241fcb 100644 --- a/web/i18n/ko-KR/app-log.json +++ b/web/i18n/ko-KR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "연 초부터 오늘까지", "filter.sortBy": "정렬 기준:", "monitoring.description": "모니터링은 성능, 사용자 활동, 비용을 포함한 애플리케이션 실행 상태를 기록합니다.", + "retention.upgradeTip.description": "업그레이드하면 업그레이드 후 생성된 모든 로그를 기간 제한 없이 보관할 수 있습니다. 업그레이드 전에 만료된 로그는 복구할 수 없습니다.", "runDetail.fileListDetail": "세부", "runDetail.fileListLabel": "파일 세부 정보", "runDetail.testWithParams": "매개변수로 테스트", diff --git a/web/i18n/nl-NL/app-log.json b/web/i18n/nl-NL/app-log.json index 79fd680f33b..a63f93b7491 100644 --- a/web/i18n/nl-NL/app-log.json +++ b/web/i18n/nl-NL/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Year to date", "filter.sortBy": "Sort by:", "monitoring.description": "Monitoring registreert de actieve status van de applicatie, inclusief prestaties, gebruikersactiviteit en kosten.", + "retention.upgradeTip.description": "Upgrade uw abonnement om alle logs die daarna worden gegenereerd onbeperkt te bewaren. Logs waarvan de bewaartermijn vóór de upgrade was verstreken, kunnen niet worden hersteld.", "runDetail.fileListDetail": "Detail", "runDetail.fileListLabel": "File Details", "runDetail.testWithParams": "Test With Params", diff --git a/web/i18n/pl-PL/app-log.json b/web/i18n/pl-PL/app-log.json index 53f77bd828f..8ec7c60945c 100644 --- a/web/i18n/pl-PL/app-log.json +++ b/web/i18n/pl-PL/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Od początku roku", "filter.sortBy": "Sortuj według:", "monitoring.description": "Monitoring rejestruje stan działania aplikacji, w tym wydajność, aktywność użytkowników i koszty.", + "retention.upgradeTip.description": "Przejdź na wyższy plan, aby bezterminowo przechowywać wszystkie logi wygenerowane po zmianie planu. Logów, których okres przechowywania upłynął przed zmianą planu, nie można odzyskać.", "runDetail.fileListDetail": "Detal", "runDetail.fileListLabel": "Szczegóły pliku", "runDetail.testWithParams": "Test z parametrami", diff --git a/web/i18n/pt-BR/app-log.json b/web/i18n/pt-BR/app-log.json index 0d980632c24..41875ffa780 100644 --- a/web/i18n/pt-BR/app-log.json +++ b/web/i18n/pt-BR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Ano até hoje", "filter.sortBy": "Ordenar por:", "monitoring.description": "O monitoramento registra o status de execução do aplicativo, incluindo desempenho, atividade dos usuários e custos.", + "retention.upgradeTip.description": "Faça upgrade do seu plano para armazenar por tempo indeterminado todos os logs gerados após o upgrade. Logs cujo período de retenção expirou antes do upgrade não podem ser recuperados.", "runDetail.fileListDetail": "Detalhe", "runDetail.fileListLabel": "Detalhes do arquivo", "runDetail.testWithParams": "Teste com parâmetros", diff --git a/web/i18n/ro-RO/app-log.json b/web/i18n/ro-RO/app-log.json index a656cbb2598..300d3ac5692 100644 --- a/web/i18n/ro-RO/app-log.json +++ b/web/i18n/ro-RO/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Anul curent", "filter.sortBy": "Sortează după:", "monitoring.description": "Monitorizarea înregistrează starea de funcționare a aplicației, inclusiv performanța, activitatea utilizatorilor și costurile.", + "retention.upgradeTip.description": "Treci la un plan superior pentru a păstra pe termen nelimitat toate jurnalele generate după upgrade. Jurnalele al căror termen de păstrare a expirat înainte de upgrade nu pot fi recuperate.", "runDetail.fileListDetail": "Amănunt", "runDetail.fileListLabel": "Detalii fișier", "runDetail.testWithParams": "Test cu parametri", diff --git a/web/i18n/ru-RU/app-log.json b/web/i18n/ru-RU/app-log.json index 1437e895a99..65a13026eed 100644 --- a/web/i18n/ru-RU/app-log.json +++ b/web/i18n/ru-RU/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "С начала года", "filter.sortBy": "Сортировать по:", "monitoring.description": "Мониторинг записывает состояние работы приложения, включая производительность, активность пользователей и затраты.", + "retention.upgradeTip.description": "Перейдите на более высокий тариф, чтобы бессрочно хранить все логи, созданные после обновления тарифа. Логи, срок хранения которых истёк до обновления тарифа, восстановить нельзя.", "runDetail.fileListDetail": "Подробность", "runDetail.fileListLabel": "Сведения о файле", "runDetail.testWithParams": "Тест с параметрами", diff --git a/web/i18n/sl-SI/app-log.json b/web/i18n/sl-SI/app-log.json index f48767f2e7a..655954dd58f 100644 --- a/web/i18n/sl-SI/app-log.json +++ b/web/i18n/sl-SI/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Leto do danes", "filter.sortBy": "Razvrsti po:", "monitoring.description": "Spremljanje beleži stanje delovanja aplikacije, vključno z zmogljivostjo, dejavnostjo uporabnikov in stroški.", + "retention.upgradeTip.description": "Nadgradite paket, da se bodo vsi dnevniki, ustvarjeni po nadgradnji, hranili brez časovne omejitve. Dnevnikov, ki jim je pred nadgradnjo potekel rok hrambe, ni mogoče obnoviti.", "runDetail.fileListDetail": "Podrobnosti", "runDetail.fileListLabel": "Podrobnosti o datoteki", "runDetail.testWithParams": "Preizkus s parametri", diff --git a/web/i18n/th-TH/app-log.json b/web/i18n/th-TH/app-log.json index 8972325ebcc..129c21717f6 100644 --- a/web/i18n/th-TH/app-log.json +++ b/web/i18n/th-TH/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "ปีจนถึงปัจจุบัน", "filter.sortBy": "เมืองสีดํา:", "monitoring.description": "การมอนิเตอร์บันทึกสถานะการทำงานของแอปพลิเคชัน รวมถึงประสิทธิภาพ กิจกรรมผู้ใช้ และค่าใช้จ่าย", + "retention.upgradeTip.description": "อัปเกรดเพื่อเก็บบันทึกทั้งหมดที่สร้างขึ้นหลังการอัปเกรดไว้โดยไม่จำกัดเวลา บันทึกที่หมดอายุก่อนการอัปเกรดไม่สามารถกู้คืนได้", "runDetail.fileListDetail": "รายละเอียด", "runDetail.fileListLabel": "รายละเอียดไฟล์", "runDetail.testWithParams": "ทดสอบด้วยพารามิเตอร์", diff --git a/web/i18n/tr-TR/app-log.json b/web/i18n/tr-TR/app-log.json index 630801a894a..522fb0b41fa 100644 --- a/web/i18n/tr-TR/app-log.json +++ b/web/i18n/tr-TR/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Yıl Başlangıcından İtibaren", "filter.sortBy": "Sıralama ölçütü:", "monitoring.description": "İzleme, performans, kullanıcı etkinliği ve maliyetler dahil olmak üzere uygulamanın çalışma durumunu kaydeder.", + "retention.upgradeTip.description": "Yükseltme sonrasında oluşturulan tüm günlükleri süresiz olarak saklamak için planınızı yükseltin. Yükseltmeden önce süresi dolan günlükler kurtarılamaz.", "runDetail.fileListDetail": "Ayrıntı", "runDetail.fileListLabel": "Dosya Detayları", "runDetail.testWithParams": "Parametrelerle Test", diff --git a/web/i18n/uk-UA/app-log.json b/web/i18n/uk-UA/app-log.json index d6fb56e1df6..3f93908c563 100644 --- a/web/i18n/uk-UA/app-log.json +++ b/web/i18n/uk-UA/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Рік до сьогодні", "filter.sortBy": "Сортувати за:", "monitoring.description": "Моніторинг фіксує робочий стан застосунку, зокрема продуктивність, активність користувачів і витрати.", + "retention.upgradeTip.description": "Перейдіть на вищий тариф, щоб безстроково зберігати всі логи, створені після оновлення тарифу. Логи, термін зберігання яких минув до оновлення тарифу, відновити неможливо.", "runDetail.fileListDetail": "Деталь", "runDetail.fileListLabel": "Подробиці файлу", "runDetail.testWithParams": "Тест з параметрами", diff --git a/web/i18n/vi-VN/app-log.json b/web/i18n/vi-VN/app-log.json index 18ed93a95a2..7cc86088e69 100644 --- a/web/i18n/vi-VN/app-log.json +++ b/web/i18n/vi-VN/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "Năm hiện tại", "filter.sortBy": "Sắp xếp theo:", "monitoring.description": "Giám sát ghi lại trạng thái hoạt động của ứng dụng, bao gồm hiệu suất, hoạt động người dùng và chi phí.", + "retention.upgradeTip.description": "Nâng cấp để lưu giữ vô thời hạn tất cả log được tạo sau khi nâng cấp; không thể khôi phục các log đã hết hạn trước khi nâng cấp.", "runDetail.fileListDetail": "Chi tiết", "runDetail.fileListLabel": "Chi tiết tệp", "runDetail.testWithParams": "Kiểm tra với các tham số", diff --git a/web/i18n/zh-Hans/app-log.json b/web/i18n/zh-Hans/app-log.json index 0e5005f60d1..38c57b9497e 100644 --- a/web/i18n/zh-Hans/app-log.json +++ b/web/i18n/zh-Hans/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "本年至今", "filter.sortBy": "排序:", "monitoring.description": "监控记录应用的运行情况,包括性能、用户活动和成本。", + "retention.upgradeTip.description": "升级即可无限期保留升级后生成的日志;此前已过期的日志无法恢复。", "runDetail.fileListDetail": "详情", "runDetail.fileListLabel": "文件详情", "runDetail.testWithParams": "按此参数测试", diff --git a/web/i18n/zh-Hant/app-log.json b/web/i18n/zh-Hant/app-log.json index b53201f82d7..59e1633f5f4 100644 --- a/web/i18n/zh-Hant/app-log.json +++ b/web/i18n/zh-Hant/app-log.json @@ -66,6 +66,7 @@ "filter.period.yearToDate": "本年至今", "filter.sortBy": "排序:", "monitoring.description": "監控記錄應用的執行情況,包括效能、使用者活動和成本。", + "retention.upgradeTip.description": "升級即可無限期保留升級後產生的日誌;此前已過期的日誌無法復原。", "runDetail.fileListDetail": "細節", "runDetail.fileListLabel": "檔詳細資訊", "runDetail.testWithParams": "使用參數測試", From 9e90b329919419b79ce6893f25ae1d3b639b1854 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:22:18 +0900 Subject: [PATCH 082/531] test: use SQLite sessions in core datasource (#39104) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../datasource/test_datasource_manager.py | 64 +++++++------- .../core/datasource/test_notion_provider.py | 83 ++++++++++++++++--- 2 files changed, 108 insertions(+), 39 deletions(-) diff --git a/api/tests/unit_tests/core/datasource/test_datasource_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_manager.py index baf51489dfb..5308a4c1922 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_manager.py @@ -1,10 +1,13 @@ import types -from collections.abc import Generator +from collections.abc import Generator, Iterator import pytest from pytest_mock import MockerFixture +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker from contexts.wrapper import RecyclableContextVar +from core.datasource import datasource_manager as datasource_manager_module from core.datasource.datasource_manager import DatasourceManager from core.datasource.entities.datasource_entities import DatasourceMessage, DatasourceProviderType from core.datasource.errors import DatasourceProviderNotFoundError @@ -12,6 +15,34 @@ from core.workflow.file_reference import parse_file_reference from graphon.enums import WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType from graphon.node_events import StreamChunkEvent, StreamCompletedEvent +from models.base import TypeBase +from models.tools import ToolFile + + +@pytest.fixture +def tool_file_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: + """Bind datasource-owned lookups to a SQLite ToolFile table.""" + TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[ToolFile.__tablename__]]) + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr(datasource_manager_module.session_factory, "create_session", session_maker) + with session_maker() as session: + yield session + + +def _persist_tool_file(session: Session, *, file_id: str, tenant_id: str) -> ToolFile: + tool_file = ToolFile( + user_id="user-1", + tenant_id=tenant_id, + conversation_id=None, + file_key="files/image.png", + mimetype="image/png", + name="image.png", + size=10, + ) + tool_file.id = file_id + session.add(tool_file) + session.commit() + return tool_file def _gen_messages_text_only(text: str) -> Generator[DatasourceMessage, None, None]: @@ -373,7 +404,8 @@ def test_stream_node_events_emits_events_online_document(mocker: MockerFixture): assert events[-1].node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED -def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture): +def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture, tool_file_session: Session): + _persist_tool_file(tool_file_session, file_id="tool_file_1", tenant_id="t1") mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored")) def _transformed(**_kwargs): @@ -418,19 +450,6 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock side_effect=_transformed, ) - fake_tool_file = types.SimpleNamespace(mimetype="image/png") - - class _Session: - def __enter__(self): - return self - - def __exit__(self, *exc): - return False - - def scalar(self, _stmt): - return fake_tool_file - - mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session()) mocker.patch("core.datasource.datasource_manager.get_file_type_by_mime_type", return_value=FileType.IMAGE) built = File( file_type=FileType.IMAGE, @@ -481,7 +500,8 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock assert events[-1].node_run_result.outputs["x"] == 1 -def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture): +def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture, tool_file_session: Session): + _persist_tool_file(tool_file_session, file_id="missing", tenant_id="other-tenant") mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored")) def _transformed(**_kwargs): @@ -496,18 +516,6 @@ def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture): side_effect=_transformed, ) - class _Session: - def __enter__(self): - return self - - def __exit__(self, *exc): - return False - - def scalar(self, _stmt): - return None - - mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session()) - with pytest.raises(ValueError, match="ToolFile not found for file_id=missing, tenant_id=t1"): list( DatasourceManager.stream_node_events( diff --git a/api/tests/unit_tests/core/datasource/test_notion_provider.py b/api/tests/unit_tests/core/datasource/test_notion_provider.py index ecbd9691e98..d68187cfb34 100644 --- a/api/tests/unit_tests/core/datasource/test_notion_provider.py +++ b/api/tests/unit_tests/core/datasource/test_notion_provider.py @@ -14,18 +14,64 @@ Tests follow the Arrange-Act-Assert pattern for clarity. """ import json +from collections.abc import Iterator +from dataclasses import dataclass from typing import Any from unittest.mock import Mock, patch +from uuid import uuid4 import httpx import pytest +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session from core.datasource.entities.datasource_entities import DatasourceProviderType from core.datasource.online_document.online_document_provider import ( OnlineDocumentDatasourcePluginProviderController, ) +from core.rag.extractor import notion_extractor as notion_extractor_module from core.rag.extractor.notion_extractor import NotionExtractor from core.rag.models.document import Document +from models.base import TypeBase +from models.dataset import Document as DocumentModel +from models.enums import DataSourceType, DocumentCreatedFrom + + +@dataclass(frozen=True) +class _Database: + """Expose the real SQLite session used by the extractor update.""" + + session: Session + + +@pytest.fixture +def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Database]: + """Bind a real session for Notion document metadata persistence.""" + + TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__]) + with Session(sqlite_engine, expire_on_commit=False) as session: + database = _Database(session) + monkeypatch.setattr(notion_extractor_module, "db", database) + yield database + + +@pytest.fixture +def persisted_document(database: _Database) -> DocumentModel: + document = DocumentModel( + id=str(uuid4()), + tenant_id=str(uuid4()), + dataset_id=str(uuid4()), + position=1, + data_source_type=DataSourceType.NOTION_IMPORT, + data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}), + batch="batch", + name="Notion page", + created_from=DocumentCreatedFrom.WEB, + created_by=str(uuid4()), + ) + database.session.add(document) + database.session.commit() + return document class TestNotionExtractorAuthentication: @@ -763,9 +809,14 @@ class TestNotionExtractorLastEditedTime: call_args = mock_request.call_args assert "databases/database-789" in call_args[0][1] - @patch("core.rag.extractor.notion_extractor.db") @patch("httpx.request") - def test_update_last_edited_time(self, mock_request, mock_db, extractor_page, mock_document_model): + def test_update_last_edited_time( + self, + mock_request: Mock, + extractor_page: NotionExtractor, + database: _Database, + persisted_document: DocumentModel, + ): """Test updating document model with last edited time.""" # Arrange mock_response = Mock() @@ -777,11 +828,11 @@ class TestNotionExtractorLastEditedTime: mock_request.return_value = mock_response # Act - extractor_page.update_last_edited_time(mock_document_model) + extractor_page.update_last_edited_time(persisted_document) # Assert - assert mock_document_model.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z" - mock_db.session.commit.assert_called_once() + database.session.expire(persisted_document) + assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z" def test_update_last_edited_time_no_document(self, extractor_page): """Test update_last_edited_time with None document model.""" @@ -807,9 +858,10 @@ class TestNotionExtractorIntegration: mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"} return mock_doc - @patch("core.rag.extractor.notion_extractor.db") @patch("httpx.request") - def test_extract_page_complete_workflow(self, mock_request, mock_db, mock_document_model): + def test_extract_page_complete_workflow( + self, mock_request: Mock, database: _Database, persisted_document: DocumentModel + ): """Test complete page extraction workflow.""" # Arrange extractor = NotionExtractor( @@ -818,7 +870,7 @@ class TestNotionExtractorIntegration: notion_page_type="page", tenant_id="tenant-789", notion_access_token="test-token", - document_model=mock_document_model, + document_model=persisted_document, ) # Mock last edited time request @@ -869,11 +921,18 @@ class TestNotionExtractorIntegration: assert isinstance(documents[0], Document) assert "# Test Page" in documents[0].page_content assert "Test content" in documents[0].page_content + database.session.expire(persisted_document) + assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z" - @patch("core.rag.extractor.notion_extractor.db") @patch("httpx.post") @patch("httpx.request") - def test_extract_database_complete_workflow(self, mock_request, mock_post, mock_db, mock_document_model): + def test_extract_database_complete_workflow( + self, + mock_request: Mock, + mock_post: Mock, + database: _Database, + persisted_document: DocumentModel, + ): """Test complete database extraction workflow.""" # Arrange extractor = NotionExtractor( @@ -882,7 +941,7 @@ class TestNotionExtractorIntegration: notion_page_type="database", tenant_id="tenant-789", notion_access_token="test-token", - document_model=mock_document_model, + document_model=persisted_document, ) # Mock last edited time request @@ -921,6 +980,8 @@ class TestNotionExtractorIntegration: assert isinstance(documents[0], Document) assert "Name:Item 1" in documents[0].page_content assert "Status:Active" in documents[0].page_content + database.session.expire(persisted_document) + assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z" def test_extract_invalid_page_type(self): """Test extract with invalid page type.""" From b100cdc382255733bf9c48e83aa3a6390f7a7ded Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:22:47 +0900 Subject: [PATCH 083/531] test: use SQLite sessions in core app (#39099) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../app/layers/test_trigger_post_layer.py | 189 ++++++++++-------- 1 file changed, 103 insertions(+), 86 deletions(-) diff --git a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py index ccdb658b491..150f05081ea 100644 --- a/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py +++ b/api/tests/unit_tests/core/app/layers/test_trigger_post_layer.py @@ -1,9 +1,13 @@ import logging +from collections.abc import Iterator +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from types import SimpleNamespace from unittest.mock import Mock, patch import pytest +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session, sessionmaker from core.app.layers.trigger_post_layer import TriggerPostLayer from core.workflow.system_variables import build_system_variables @@ -13,19 +17,63 @@ from graphon.graph_events import ( GraphRunSucceededEvent, ) from graphon.runtime import VariablePool -from models.enums import WorkflowTriggerStatus +from models.enums import AppTriggerType, CreatorUserRole, WorkflowTriggerStatus +from models.trigger import WorkflowTriggerLog + + +@dataclass(frozen=True) +class TriggerDatabase: + session: Session + statements: list[str] + + +@pytest.fixture(autouse=True) +def trigger_database(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[TriggerDatabase]: + """Create the trigger-log table and bind layer-owned sessions to SQLite.""" + WorkflowTriggerLog.metadata.create_all(sqlite_engine, tables=[WorkflowTriggerLog.__table__]) + sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker) + statements: list[str] = [] + + def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None: + statements.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + with sqlite_session_maker() as session: + try: + yield TriggerDatabase(session=session, statements=statements) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) + + +def _persist_trigger_log(database: TriggerDatabase, *, trigger_log_id: str = "log-1") -> WorkflowTriggerLog: + trigger_log = WorkflowTriggerLog( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id=None, + root_node_id=None, + trigger_metadata="{}", + trigger_type=AppTriggerType.TRIGGER_WEBHOOK, + trigger_data="{}", + inputs="{}", + outputs=None, + status=WorkflowTriggerStatus.RUNNING, + error=None, + queue_name="workflow", + celery_task_id=None, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-1", + ) + trigger_log.id = trigger_log_id + database.session.add(trigger_log) + database.session.commit() + return trigger_log class TestTriggerPostLayer: - def test_on_event_updates_trigger_log(self): - trigger_log = SimpleNamespace( - status=None, - workflow_run_id=None, - outputs=None, - elapsed_time=None, - total_tokens=None, - finished_at=None, - ) + def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase): + trigger_log = _persist_trigger_log(trigger_database) runtime_state = SimpleNamespace( outputs={"answer": "ok"}, variable_pool=VariablePool.from_bootstrap( @@ -35,19 +83,10 @@ class TestTriggerPostLayer: ) with ( - patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory, - patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls, patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime, ): mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC) - session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = session - - repo = Mock() - repo.get_by_id.return_value = trigger_log - mock_repo_cls.return_value = repo - layer = TriggerPostLayer( cfs_plan_scheduler_entity=Mock(), start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10), @@ -57,25 +96,18 @@ class TestTriggerPostLayer: layer.on_event(GraphRunSucceededEvent()) - assert trigger_log.status == WorkflowTriggerStatus.SUCCEEDED - assert trigger_log.workflow_run_id == "run-1" - assert trigger_log.outputs is not None - assert trigger_log.elapsed_time is not None - assert trigger_log.total_tokens == 12 - assert trigger_log.finished_at is not None - repo.update.assert_called_once_with(trigger_log) - session.commit.assert_called_once() + trigger_database.session.expire_all() + persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id) + assert persisted_log is not None + assert persisted_log.status == WorkflowTriggerStatus.SUCCEEDED + assert persisted_log.workflow_run_id == "run-1" + assert persisted_log.outputs == '{"answer":"ok"}' + assert persisted_log.elapsed_time == 10 + assert persisted_log.total_tokens == 12 + assert persisted_log.finished_at is not None - def test_on_event_updates_trigger_log_for_aborted_event(self): - trigger_log = SimpleNamespace( - status=None, - workflow_run_id=None, - outputs=None, - error=None, - elapsed_time=None, - total_tokens=None, - finished_at=None, - ) + def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase): + trigger_log = _persist_trigger_log(trigger_database) runtime_state = SimpleNamespace( outputs={"partial": "ok"}, variable_pool=VariablePool.from_bootstrap( @@ -85,19 +117,10 @@ class TestTriggerPostLayer: ) with ( - patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory, - patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls, patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime, ): mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC) - session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = session - - repo = Mock() - repo.get_by_id.return_value = trigger_log - mock_repo_cls.return_value = repo - layer = TriggerPostLayer( cfs_plan_scheduler_entity=Mock(), start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10), @@ -107,17 +130,22 @@ class TestTriggerPostLayer: layer.on_event(GraphRunAbortedEvent(reason="timeout")) - assert trigger_log.status == WorkflowTriggerStatus.FAILED - assert trigger_log.workflow_run_id == "run-1" - assert trigger_log.outputs is not None - assert trigger_log.error == "timeout" - assert trigger_log.elapsed_time is not None - assert trigger_log.total_tokens == 7 - assert trigger_log.finished_at is not None - repo.update.assert_called_once_with(trigger_log) - session.commit.assert_called_once() + trigger_database.session.expire_all() + persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id) + assert persisted_log is not None + assert persisted_log.status == WorkflowTriggerStatus.FAILED + assert persisted_log.workflow_run_id == "run-1" + assert persisted_log.outputs == '{"partial":"ok"}' + assert persisted_log.error == "timeout" + assert persisted_log.elapsed_time == 10 + assert persisted_log.total_tokens == 7 + assert persisted_log.finished_at is not None - def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture): + def test_on_event_handles_missing_trigger_log( + self, + caplog: pytest.LogCaptureFixture, + trigger_database: TriggerDatabase, + ): runtime_state = SimpleNamespace( outputs={}, variable_pool=VariablePool.from_bootstrap( @@ -126,31 +154,20 @@ class TestTriggerPostLayer: total_tokens=0, ) - with ( - patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory, - patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls, - ): - session = Mock() - mock_session_factory.create_session.return_value.__enter__.return_value = session + layer = TriggerPostLayer( + cfs_plan_scheduler_entity=Mock(), + start_time=datetime(2026, 2, 20, tzinfo=UTC), + trigger_log_id="missing", + ) + layer.initialize(runtime_state, Mock()) - repo = Mock() - repo.get_by_id.return_value = None - mock_repo_cls.return_value = repo - - layer = TriggerPostLayer( - cfs_plan_scheduler_entity=Mock(), - start_time=datetime(2026, 2, 20, tzinfo=UTC), - trigger_log_id="missing", - ) - layer.initialize(runtime_state, Mock()) - - with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"): - layer.on_event(GraphRunFailedEvent(error="boom")) + with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"): + layer.on_event(GraphRunFailedEvent(error="boom")) assert any(record.levelno == logging.ERROR for record in caplog.records) - session.commit.assert_not_called() + assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None - def test_on_event_ignores_non_status_events(self): + def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase): runtime_state = SimpleNamespace( outputs={}, variable_pool=VariablePool.from_bootstrap( @@ -159,14 +176,14 @@ class TestTriggerPostLayer: total_tokens=0, ) - with patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory: - layer = TriggerPostLayer( - cfs_plan_scheduler_entity=Mock(), - start_time=datetime(2026, 2, 20, tzinfo=UTC), - trigger_log_id="log-1", - ) - layer.initialize(runtime_state, Mock()) + layer = TriggerPostLayer( + cfs_plan_scheduler_entity=Mock(), + start_time=datetime(2026, 2, 20, tzinfo=UTC), + trigger_log_id="log-1", + ) + layer.initialize(runtime_state, Mock()) - layer.on_event(Mock()) + trigger_database.statements.clear() + layer.on_event(Mock()) - mock_session_factory.create_session.assert_not_called() + assert trigger_database.statements == [] From f3f2f6311035ab52817cbd18044e06e48462fc12 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:26:52 +0900 Subject: [PATCH 084/531] test: use SQLite in LangSmith traces (#38986) --- .../langsmith_trace/test_langsmith_trace.py | 96 +++++++++++++------ 1 file changed, 67 insertions(+), 29 deletions(-) diff --git a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py index 76d4c99caf7..f9406e13048 100644 --- a/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/langsmith_trace/test_langsmith_trace.py @@ -1,5 +1,8 @@ +"""Unit tests for LangSmith trace translation with SQLite-backed lookups.""" + import collections from datetime import datetime, timedelta +from types import SimpleNamespace from typing import override from unittest.mock import MagicMock @@ -11,6 +14,7 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import ( LangSmithRunUpdateModel, ) from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace +from sqlalchemy.orm import Session from core.ops.entities.trace_entity import ( DatasetRetrievalTraceInfo, @@ -24,6 +28,7 @@ from core.ops.entities.trace_entity import ( ) from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey from models import EndUser +from models.enums import EndUserType def _dt() -> datetime: @@ -108,7 +113,8 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch): mocks["generate_name_trace"].assert_called_once_with(info) -def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [()], indirect=True) +def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None: # Setup trace info workflow_data = MagicMock() workflow_data.created_at = _dt() @@ -137,10 +143,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): workflow_data=workflow_data, ) - # Mock dependencies - mock_session = MagicMock() - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr( + "dify_trace_langsmith.langsmith_trace.db", + SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session), + ) # Mock node executions node_llm = MagicMock() @@ -228,7 +234,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): assert call_args[4].run_type == LangSmithRunType.retriever -def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [()], indirect=True) +def test_workflow_trace_no_start_time( + trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session +) -> None: workflow_data = MagicMock() workflow_data.created_at = _dt() workflow_data.finished_at = _dt() + timedelta(seconds=1) @@ -256,9 +265,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey workflow_data=workflow_data, ) - mock_session = MagicMock() - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr( + "dify_trace_langsmith.langsmith_trace.db", + SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session), + ) repo = MagicMock() repo.get_by_workflow_execution.return_value = [] mock_factory = MagicMock() @@ -271,7 +281,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey assert trace_instance.add_run.called -def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [()], indirect=True) +def test_workflow_trace_missing_app_id( + trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session +) -> None: trace_info = MagicMock(spec=WorkflowTraceInfo) trace_info.trace_id = "trace-1" trace_info.message_id = None @@ -287,15 +300,17 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.Monke trace_info.workflow_run_outputs = {} trace_info.error = "" - mock_session = MagicMock() - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session) - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr( + "dify_trace_langsmith.langsmith_trace.db", + SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session), + ) with pytest.raises(ValueError, match="No app_id found in trace_info metadata"): trace_instance.workflow_trace(trace_info) -def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True) +def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None: message_data = MagicMock() message_data.id = "msg-1" message_data.from_account_id = "acc-1" @@ -321,10 +336,19 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch): message_file_data=MagicMock(url="file-url"), ) - # Mock EndUser lookup - mock_end_user = MagicMock(spec=EndUser) - mock_end_user.session_id = "session-id-123" - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user) + end_user = EndUser( + id="end-user-1", + tenant_id="tenant-1", + app_id="app-1", + type=EndUserType.BROWSER, + session_id="session-id-123", + ) + sqlite3_session.add(end_user) + sqlite3_session.commit() + monkeypatch.setattr( + "dify_trace_langsmith.langsmith_trace.db", + SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session), + ) trace_instance.add_run = MagicMock() @@ -521,9 +545,13 @@ def test_update_run_error(trace_instance): trace_instance.update_run(update_data) +@pytest.mark.parametrize("sqlite3_session", [()], indirect=True) def test_workflow_trace_usage_extraction_error( - trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture -): + trace_instance, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + sqlite3_session: Session, +) -> None: workflow_data = MagicMock() workflow_data.created_at = _dt() workflow_data.finished_at = _dt() + timedelta(seconds=1) @@ -576,8 +604,10 @@ def test_workflow_trace_usage_extraction_error( mock_factory = MagicMock() mock_factory.create_workflow_node_execution_repository.return_value = repo monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory) - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) + monkeypatch.setattr( + "dify_trace_langsmith.langsmith_trace.db", + SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session), + ) monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock()) trace_instance.add_run = MagicMock() @@ -644,9 +674,11 @@ def _make_workflow_trace_info( ) -def _patch_workflow_trace_deps(monkeypatch, trace_instance): - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock()) - monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine")) +def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None: + monkeypatch.setattr( + "dify_trace_langsmith.langsmith_trace.db", + SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session), + ) repo = MagicMock() repo.get_by_workflow_execution.return_value = [] factory = MagicMock() @@ -656,14 +688,17 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance): trace_instance.add_run = MagicMock() -def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [()], indirect=True) +def test_workflow_trace_id_uses_message_id_not_external( + trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session +) -> None: """Chatflow with external trace_id: LangSmith trace_id must be message_id, not external.""" trace_info = _make_workflow_trace_info( message_id="msg-abc", workflow_run_id="run-xyz", trace_id="external-999", ) - _patch_workflow_trace_deps(monkeypatch, trace_instance) + _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session) trace_instance.workflow_trace(trace_info) @@ -677,14 +712,17 @@ def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypa assert trace_info.metadata.get("external_trace_id") == "external-999" -def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [()], indirect=True) +def test_workflow_trace_id_pure_workflow_uses_run_id( + trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session +) -> None: """Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id.""" trace_info = _make_workflow_trace_info( message_id=None, workflow_run_id="run-xyz", trace_id="external-999", ) - _patch_workflow_trace_deps(monkeypatch, trace_instance) + _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session) trace_instance.workflow_trace(trace_info) From 698869460c726056661160cbb902e0120e0c0f9d Mon Sep 17 00:00:00 2001 From: -LAN- Date: Tue, 28 Jul 2026 13:34:35 +0800 Subject: [PATCH 085/531] chore(docker): remove redundant feature preview env (#39035) --- docker/.env.example | 3 --- 1 file changed, 3 deletions(-) diff --git a/docker/.env.example b/docker/.env.example index 0bc034d004b..20ed14db271 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -157,9 +157,6 @@ ENABLE_WEBSITE_JINAREADER=true ENABLE_WEBSITE_FIRECRAWL=true ENABLE_WEBSITE_WATERCRAWL=true NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false -# Enable preview features still in development (currently the /create and -# /refine slash commands in the "Go to Anything" command palette). -NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true NEXT_PUBLIC_ENABLE_AGENT_V2=true EXPERIMENTAL_ENABLE_VINEXT=false From ea58129ebeec91fbe3cd6ef685dc0a76c2d268db Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:34:59 +0900 Subject: [PATCH 086/531] test: move workflow app service coverage to unit tests (#38946) --- .../services/test_workflow_app_service.py | 75 +--------------- .../test_workflow_app_service_metadata.py | 88 +++++++++++++++++++ 2 files changed, 90 insertions(+), 73 deletions(-) create mode 100644 api/tests/unit_tests/services/test_workflow_app_service_metadata.py diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py index f553b0f72a0..71c9c81e0d2 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py @@ -3,7 +3,6 @@ from __future__ import annotations import json import uuid from datetime import UTC, datetime, timedelta -from types import SimpleNamespace from unittest.mock import patch import pytest @@ -12,13 +11,13 @@ from sqlalchemy.orm import Session from graphon.enums import WorkflowExecutionStatus from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun -from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom +from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.workflow import WorkflowAppLogCreatedFrom from services.account_service import AccountService, TenantService # Delay import of AppService to avoid circular dependency # from services.app_service import AppService, CreateAppParams -from services.workflow_app_service import LogView, WorkflowAppService +from services.workflow_app_service import WorkflowAppService from tests.test_containers_integration_tests.helpers import generate_valid_password @@ -1627,73 +1626,3 @@ class TestWorkflowAppService: end_user_item = next(d for d in result["data"] if d["created_by_end_user"] is not None) assert account_item["created_by_account"].id == account.id assert end_user_item["created_by_end_user"].id == end_user.id - - -class TestLogView: - def test_details_and_proxy_attributes(self): - log = SimpleNamespace(id="log-1", status="succeeded") - view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}}) - - assert view.details == {"trigger_metadata": {"type": "plugin"}} - assert view.status == "succeeded" - - -class TestHandleTriggerMetadata: - def test_returns_empty_dict_when_metadata_missing(self): - service = WorkflowAppService() - assert service.handle_trigger_metadata("tenant-1", None) == {} - - def test_enriches_plugin_icons(self): - service = WorkflowAppService() - meta = { - "type": AppTriggerType.TRIGGER_PLUGIN.value, - "icon_filename": "light.png", - "icon_dark_filename": "dark.png", - } - with patch( - "services.workflow_app_service.PluginService.get_plugin_icon_url", - side_effect=["https://cdn/light.png", "https://cdn/dark.png"], - ) as mock_icon: - result = service.handle_trigger_metadata("tenant-1", json.dumps(meta)) - - assert result["icon"] == "https://cdn/light.png" - assert result["icon_dark"] == "https://cdn/dark.png" - assert mock_icon.call_count == 2 - - def test_non_plugin_metadata_without_icon_lookup(self): - service = WorkflowAppService() - meta = {"type": AppTriggerType.TRIGGER_WEBHOOK.value} - with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon: - result = service.handle_trigger_metadata("tenant-1", json.dumps(meta)) - - assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value - mock_icon.assert_not_called() - - -class TestSafeJsonLoads: - @pytest.mark.parametrize( - ("value", "expected"), - [ - (None, None), - ("", None), - ('{"k":"v"}', {"k": "v"}), - ("not-json", None), - ({"raw": True}, {"raw": True}), - ], - ) - def test_handles_various_inputs(self, value, expected): - assert WorkflowAppService._safe_json_loads(value) == expected - - -class TestSafeParseUuid: - def test_returns_none_for_short_or_invalid_values(self): - service = WorkflowAppService() - assert service._safe_parse_uuid("short") is None - assert service._safe_parse_uuid("x" * 40) is None - - def test_returns_uuid_for_valid_string(self): - service = WorkflowAppService() - raw = str(uuid.uuid4()) - result = service._safe_parse_uuid(raw) - assert result is not None - assert str(result) == raw diff --git a/api/tests/unit_tests/services/test_workflow_app_service_metadata.py b/api/tests/unit_tests/services/test_workflow_app_service_metadata.py new file mode 100644 index 00000000000..ded4fab1c7a --- /dev/null +++ b/api/tests/unit_tests/services/test_workflow_app_service_metadata.py @@ -0,0 +1,88 @@ +"""Unit tests for workflow app log views and trigger metadata helpers.""" + +import json +import uuid +from unittest.mock import patch + +import pytest + +from models.enums import AppTriggerType, CreatorUserRole +from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom +from services.workflow_app_service import LogView, WorkflowAppService + + +class TestLogView: + def test_details_and_proxy_attributes(self) -> None: + log = WorkflowAppLog( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="run-1", + created_from=WorkflowAppLogCreatedFrom.WEB_APP, + created_by_role=CreatorUserRole.ACCOUNT, + created_by="account-1", + ) + log.id = "log-1" + + view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}}) + + assert view.details == {"trigger_metadata": {"type": "plugin"}} + assert view.id == "log-1" + + +class TestHandleTriggerMetadata: + def test_returns_empty_dict_when_metadata_missing(self) -> None: + assert WorkflowAppService().handle_trigger_metadata("tenant-1", None) == {} + + def test_enriches_plugin_icons(self) -> None: + metadata = { + "type": AppTriggerType.TRIGGER_PLUGIN.value, + "icon_filename": "light.png", + "icon_dark_filename": "dark.png", + } + with patch( + "services.workflow_app_service.PluginService.get_plugin_icon_url", + side_effect=["https://cdn/light.png", "https://cdn/dark.png"], + ) as mock_icon: + result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata)) + + assert result["icon"] == "https://cdn/light.png" + assert result["icon_dark"] == "https://cdn/dark.png" + assert mock_icon.call_count == 2 + + def test_non_plugin_metadata_without_icon_lookup(self) -> None: + metadata = {"type": AppTriggerType.TRIGGER_WEBHOOK.value} + with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon: + result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata)) + + assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value + mock_icon.assert_not_called() + + +class TestSafeJsonLoads: + @pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("", None), + ('{"k":"v"}', {"k": "v"}), + ("not-json", None), + ({"raw": True}, {"raw": True}), + ], + ) + def test_handles_various_inputs(self, value, expected) -> None: + assert WorkflowAppService._safe_json_loads(value) == expected + + +class TestSafeParseUuid: + def test_returns_none_for_short_or_invalid_values(self) -> None: + assert WorkflowAppService._safe_parse_uuid("short") is None + assert WorkflowAppService._safe_parse_uuid("x" * 40) is None + + def test_returns_uuid_for_valid_string(self) -> None: + raw = str(uuid.uuid4()) + + result = WorkflowAppService._safe_parse_uuid(raw) + + assert result is not None + assert str(result) == raw From 80ff108fc06150a5710e30fb1900d6155337aa2d Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:40:27 +0900 Subject: [PATCH 087/531] test: use SQLite in Aliyun trace utilities (#38983) --- .../aliyun_trace/test_aliyun_trace_utils.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py index 0900dfda97f..4cdbb773a90 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py @@ -1,3 +1,5 @@ +"""Unit tests for Aliyun trace utility transformations and database lookups.""" + import json from collections.abc import Mapping from typing import Any, cast @@ -25,11 +27,13 @@ from dify_trace_aliyun.utils import ( serialize_json_data, ) from opentelemetry.trace import Link, StatusCode +from sqlalchemy.orm import Session from core.rag.models.document import Document from graphon.entities import WorkflowNodeExecution from graphon.enums import WorkflowNodeExecutionStatus from models import EndUser +from models.enums import EndUserType def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch): @@ -40,35 +44,40 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat assert get_user_id_from_message_data(message_data) == "account_id" -def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True) +def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None: message_data = MagicMock() message_data.from_account_id = "account_id" message_data.from_end_user_id = "end_user_id" - end_user_data = MagicMock(spec=EndUser) - end_user_data.session_id = "session_id" - - mock_session = MagicMock() - mock_session.get.return_value = end_user_data + end_user_data = EndUser( + id="end_user_id", + tenant_id="tenant_id", + app_id="app_id", + type=EndUserType.BROWSER, + session_id="session_id", + ) + sqlite3_session.add(end_user_data) + sqlite3_session.commit() from dify_trace_aliyun.utils import db - monkeypatch.setattr(db, "session", mock_session) + monkeypatch.setattr(db, "session", sqlite3_session) assert get_user_id_from_message_data(message_data) == "session_id" -def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch): +@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True) +def test_get_user_id_from_message_data_end_user_not_found( + monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session +) -> None: message_data = MagicMock() message_data.from_account_id = "account_id" message_data.from_end_user_id = "end_user_id" - mock_session = MagicMock() - mock_session.get.return_value = None - from dify_trace_aliyun.utils import db - monkeypatch.setattr(db, "session", mock_session) + monkeypatch.setattr(db, "session", sqlite3_session) assert get_user_id_from_message_data(message_data) == "account_id" From 0913d04d33c472cc242e03446099766fc95e0067 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:47:01 +0900 Subject: [PATCH 088/531] test: use SQLite sessions in services retention (#39074) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../test_restore_archived_workflow_run.py | 483 ++++++++---------- 1 file changed, 213 insertions(+), 270 deletions(-) diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_restore_archived_workflow_run.py b/api/tests/unit_tests/services/retention/workflow_run/test_restore_archived_workflow_run.py index 4768215210e..62c042ff2cf 100644 --- a/api/tests/unit_tests/services/retention/workflow_run/test_restore_archived_workflow_run.py +++ b/api/tests/unit_tests/services/retention/workflow_run/test_restore_archived_workflow_run.py @@ -10,14 +10,19 @@ import io import json import logging import zipfile +from collections.abc import Iterator +from dataclasses import dataclass from datetime import datetime -from unittest.mock import Mock, create_autospec, patch +from unittest.mock import Mock, patch import pytest from pydantic import ValidationError -from sqlalchemy import Column, Integer, MetaData, String, Table +from sqlalchemy import Column, Engine, Integer, MetaData, String, Table, delete, event, func, select +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.orm import Session, sessionmaker from libs.archive_storage import ArchiveStorageNotConfiguredError +from models.enums import CreatorUserRole from models.trigger import WorkflowTriggerLog from models.workflow import ( WorkflowAppLog, @@ -28,6 +33,7 @@ from models.workflow import ( WorkflowPauseReason, WorkflowRun, ) +from services.retention.workflow_run import restore_archived_workflow_run as restore_module from services.retention.workflow_run.restore_archived_workflow_run import ( SCHEMA_MAPPERS, TABLE_MODELS, @@ -36,24 +42,49 @@ from services.retention.workflow_run.restore_archived_workflow_run import ( ) +@dataclass(frozen=True) +class Database: + """Explicit SQLite engine, caller session, and real service-owned session factory.""" + + engine: Engine + session: Session + session_maker: sessionmaker[Session] + + +@pytest.fixture +def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]: + WorkflowRun.metadata.create_all( + sqlite_engine, + tables=[WorkflowRun.__table__, WorkflowAppLog.__table__, WorkflowArchiveLog.__table__], + ) + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + with session_maker() as session: + database = Database(engine=sqlite_engine, session=session, session_maker=session_maker) + monkeypatch.setattr(restore_module, "db", database) + # Production constructs PostgreSQL's equivalent statement; SQLite's + # dialect keeps the conflict behavior executable in these tests. + monkeypatch.setattr(restore_module, "pg_insert", sqlite_insert) + yield database + + class WorkflowRunRestoreTestDataFactory: """ - Factory for creating test data and mock objects. + Factory for creating persisted-model-compatible test data. Provides reusable methods to create consistent mock objects for testing workflow run restore operations. """ @staticmethod - def create_workflow_run_mock( + def create_workflow_run( run_id: str = "run-123", tenant_id: str = "tenant-123", app_id: str = "app-123", created_at: datetime | None = None, **kwargs, - ) -> Mock: + ) -> WorkflowRun: """ - Create a mock WorkflowRun object. + Create a concrete WorkflowRun object. Args: run_id: Unique identifier for the workflow run @@ -63,27 +94,44 @@ class WorkflowRunRestoreTestDataFactory: **kwargs: Additional attributes to set on the mock Returns: - Mock WorkflowRun object with specified attributes + WorkflowRun object with specified attributes """ - run = create_autospec(WorkflowRun, instance=True) - run.id = run_id - run.tenant_id = tenant_id - run.app_id = app_id - run.created_at = created_at or datetime(2024, 1, 1, 12, 0, 0) - for key, value in kwargs.items(): - setattr(run, key, value) + attrs = { + "id": run_id, + "tenant_id": tenant_id, + "app_id": app_id, + "workflow_id": "workflow-123", + "type": "workflow", + "triggered_from": "app-run", + "version": "1", + "graph": None, + "inputs": None, + "status": "succeeded", + "outputs": "{}", + "error": None, + "elapsed_time": 0, + "total_tokens": 0, + "total_steps": 0, + "created_by_role": CreatorUserRole.ACCOUNT, + "created_by": "user-123", + "created_at": created_at or datetime(2024, 1, 1, 12, 0, 0), + "finished_at": None, + "exceptions_count": 0, + } + attrs.update(kwargs) + run = WorkflowRun(**attrs) return run @staticmethod - def create_workflow_archive_log_mock( + def create_workflow_archive_log( run_id: str = "run-123", tenant_id: str = "tenant-123", app_id: str = "app-123", created_at: datetime | None = None, **kwargs, - ) -> Mock: + ) -> WorkflowArchiveLog: """ - Create a mock WorkflowArchiveLog object. + Create a concrete WorkflowArchiveLog object. Args: run_id: Unique identifier for the workflow run @@ -93,16 +141,32 @@ class WorkflowRunRestoreTestDataFactory: **kwargs: Additional attributes to set on the mock Returns: - Mock WorkflowArchiveLog object with specified attributes + WorkflowArchiveLog object with specified attributes """ - archive_log = create_autospec(WorkflowArchiveLog, instance=True) - archive_log.workflow_run_id = run_id - archive_log.tenant_id = tenant_id - archive_log.app_id = app_id - archive_log.run_created_at = created_at or datetime(2024, 1, 1, 12, 0, 0) - for key, value in kwargs.items(): - setattr(archive_log, key, value) - return archive_log + attrs = { + "tenant_id": tenant_id, + "app_id": app_id, + "workflow_id": "workflow-123", + "workflow_run_id": run_id, + "created_by_role": CreatorUserRole.ACCOUNT, + "created_by": "user-123", + "log_id": None, + "log_created_at": None, + "log_created_from": None, + "run_version": "1", + "run_status": "succeeded", + "run_triggered_from": "app-run", + "run_error": None, + "run_elapsed_time": 0, + "run_total_tokens": 0, + "run_total_steps": 0, + "run_created_at": created_at or datetime(2024, 1, 1, 12, 0, 0), + "run_finished_at": None, + "run_exceptions_count": 0, + "trigger_metadata": None, + } + attrs.update(kwargs) + return WorkflowArchiveLog(**attrs) @staticmethod def create_archive_zip_mock( @@ -137,7 +201,7 @@ class WorkflowRunRestoreTestDataFactory: "app_id": "app-123", "workflow_id": "workflow-123", "type": "workflow", - "triggered_from": "app", + "triggered_from": "app-run", "version": "1", "status": "succeeded", "created_by_role": "account", @@ -151,7 +215,7 @@ class WorkflowRunRestoreTestDataFactory: "app_id": "app-123", "workflow_id": "workflow-123", "workflow_run_id": "run-123", - "created_from": "app", + "created_from": "service-api", "created_by_role": "account", "created_by": "user-123", }, @@ -161,7 +225,7 @@ class WorkflowRunRestoreTestDataFactory: "app_id": "app-123", "workflow_id": "workflow-123", "workflow_run_id": "run-123", - "created_from": "app", + "created_from": "service-api", "created_by_role": "account", "created_by": "user-123", }, @@ -225,14 +289,10 @@ class TestGetWorkflowRunRepo: """Tests for WorkflowRunRestore._get_workflow_run_repo method.""" @patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory") - @patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker") - @patch("services.retention.workflow_run.restore_archived_workflow_run.db") - def test_first_call_creates_repo(self, mock_db, mock_sessionmaker, mock_factory): + def test_first_call_creates_repo(self, mock_factory, database: Database): """First call should create and cache repository.""" restore = WorkflowRunRestore() - mock_session = Mock() - mock_sessionmaker.return_value = mock_session mock_repo = Mock() mock_factory.create_api_workflow_run_repository.return_value = mock_repo @@ -240,8 +300,9 @@ class TestGetWorkflowRunRepo: assert result is mock_repo assert restore.workflow_run_repo is mock_repo - mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False) - mock_factory.create_api_workflow_run_repository.assert_called_once_with(mock_session) + session_maker = mock_factory.create_api_workflow_run_repository.call_args.args[0] + assert isinstance(session_maker, sessionmaker) + assert session_maker.kw["bind"] is database.engine def test_cached_repo_returned(self): """Subsequent calls should return cached repository.""" @@ -492,47 +553,27 @@ class TestGetModelColumnInfo: class TestRestoreTableRecords: """Tests for WorkflowRunRestore._restore_table_records method.""" - @patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS") - def test_unknown_table_returns_zero(self, mock_table_models, caplog: pytest.LogCaptureFixture): + def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture): """Should return 0 for unknown table.""" restore = WorkflowRunRestore() - mock_table_models.get.return_value = None - - mock_session = Mock() records = [{"id": "test"}] caplog.set_level(logging.WARNING, logger="services.retention.workflow_run.restore_archived_workflow_run") - result = restore._restore_table_records(mock_session, "unknown_table", records, schema_version="1.0") + result = restore._restore_table_records(database.session, "unknown_table", records, schema_version="1.0") assert result == 0 assert "Unknown table: unknown_table" in caplog.messages - def test_empty_records_returns_zero(self): + def test_empty_records_returns_zero(self, database: Database): """Should return 0 for empty records list.""" restore = WorkflowRunRestore() - mock_session = Mock() - - result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0") + result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0") assert result == 0 - @patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") - @patch("services.retention.workflow_run.restore_archived_workflow_run.cast") - def test_successful_restore(self, mock_cast, mock_pg_insert): + def test_successful_restore(self, database: Database): """Should successfully restore records.""" restore = WorkflowRunRestore() - # Mock session and execution - mock_session = Mock() - mock_result = Mock() - mock_result.rowcount = 2 - mock_session.execute.return_value = mock_result - mock_cast.return_value = mock_result - - # Mock insert statement - mock_stmt = Mock() - mock_stmt.on_conflict_do_nothing.return_value = mock_stmt - mock_pg_insert.return_value = mock_stmt - records = [ { "id": "test1", @@ -540,7 +581,7 @@ class TestRestoreTableRecords: "app_id": "app-123", "workflow_id": "workflow-123", "type": "workflow", - "triggered_from": "app", + "triggered_from": "app-run", "version": "1", "status": "succeeded", "created_by_role": "account", @@ -552,7 +593,7 @@ class TestRestoreTableRecords: "app_id": "app-123", "workflow_id": "workflow-123", "type": "workflow", - "triggered_from": "app", + "triggered_from": "app-run", "version": "1", "status": "succeeded", "created_by_role": "account", @@ -560,38 +601,20 @@ class TestRestoreTableRecords: }, ] - result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0") + result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") assert result == 2 - mock_session.execute.assert_called_once() + assert database.session.scalar(select(func.count(WorkflowRun.id))) == 2 + assert restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") == 0 - def test_missing_required_columns_raises_error(self): + def test_missing_required_columns_raises_error(self, database: Database): """Should raise ValueError for missing required columns.""" restore = WorkflowRunRestore() - mock_session = Mock() - # Use a dedicated mock model to isolate required-column validation behavior. - mock_model = Mock() + records = [{"id": "test"}] - # Mock a required column - required_column = Mock() - required_column.key = "required_field" - required_column.nullable = False - required_column.default = None - required_column.server_default = None - required_column.autoincrement = False - required_column.type = Mock() - - # Mock the __table__ attribute properly - mock_table = Mock() - mock_table.columns = [required_column] - mock_model.__table__ = mock_table - - records = [{"name": "test"}] # Missing required 'required_field' - - with patch.dict(TABLE_MODELS, {"test_table": mock_model}): - with pytest.raises(ValueError, match="Missing required columns for test_table"): - restore._restore_table_records(mock_session, "test_table", records, schema_version="1.0") + with pytest.raises(ValueError, match="Missing required columns for workflow_runs"): + restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") # --------------------------------------------------------------------------- @@ -603,38 +626,38 @@ class TestRestoreFromRun: """Tests for WorkflowRunRestore._restore_from_run method.""" @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") - def test_archive_storage_not_configured(self, mock_get_storage): + def test_archive_storage_not_configured(self, mock_get_storage, database: Database): """Should handle ArchiveStorageNotConfiguredError.""" restore = WorkflowRunRestore() mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured") - run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock() + run = WorkflowRunRestoreTestDataFactory.create_workflow_run() with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - result = restore._restore_from_run(run, session_maker=lambda: Mock()) + result = restore._restore_from_run(run, session_maker=database.session_maker) assert result.success is False assert "Storage not configured" in result.error assert result.elapsed_time > 0 @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") - def test_archive_bundle_not_found(self, mock_get_storage): + def test_archive_bundle_not_found(self, mock_get_storage, database: Database): """Should handle FileNotFoundError when archive bundle is missing.""" restore = WorkflowRunRestore() mock_storage = Mock() mock_storage.get_object.side_effect = FileNotFoundError("Bundle not found") mock_get_storage.return_value = mock_storage - run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock() + run = WorkflowRunRestoreTestDataFactory.create_workflow_run() with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - result = restore._restore_from_run(run, session_maker=lambda: Mock()) + result = restore._restore_from_run(run, session_maker=database.session_maker) assert result.success is False assert "Archive bundle not found" in result.error @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") - def test_dry_run_mode(self, mock_get_storage): + def test_dry_run_mode(self, mock_get_storage, database: Database): """Should handle dry run mode correctly.""" restore = WorkflowRunRestore(dry_run=True) @@ -644,23 +667,16 @@ class TestRestoreFromRun: mock_storage.get_object.return_value = archive_data mock_get_storage.return_value = mock_storage - run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock() + run = WorkflowRunRestoreTestDataFactory.create_workflow_run() - # Create a proper mock session with context manager support - mock_session = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=None) - - result = restore._restore_from_run(run, session_maker=lambda: mock_session) + result = restore._restore_from_run(run, session_maker=database.session_maker) assert result.success is True assert result.restored_counts["workflow_runs"] == 1 assert result.restored_counts["workflow_app_logs"] == 2 @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") - @patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") - @patch("services.retention.workflow_run.restore_archived_workflow_run.cast") - def test_successful_restore(self, mock_cast, mock_pg_insert, mock_get_storage): + def test_successful_restore(self, mock_get_storage, database: Database): """Should successfully restore from archive.""" restore = WorkflowRunRestore() @@ -670,53 +686,57 @@ class TestRestoreFromRun: mock_storage.get_object.return_value = archive_data mock_get_storage.return_value = mock_storage - # Mock session with context manager support - mock_session = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=None) - - def session_maker(): - return mock_session - - # Mock database execution to return integer counts - mock_result_workflow_runs = Mock() - mock_result_workflow_runs.rowcount = 1 - mock_result_app_logs = Mock() - mock_result_app_logs.rowcount = 2 - - # Configure session.execute to return different results based on the table - def mock_execute(stmt): - if "workflow_runs" in str(stmt): - return mock_result_workflow_runs - else: - return mock_result_app_logs - - mock_session.execute.side_effect = mock_execute - mock_cast.return_value = mock_result_workflow_runs - - # Mock insert statement - mock_stmt = Mock() - mock_stmt.on_conflict_do_nothing.return_value = mock_stmt - mock_pg_insert.return_value = mock_stmt - - run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock() + run = WorkflowRunRestoreTestDataFactory.create_workflow_run() + archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log() + database.session.add(archive_log) + database.session.commit() # Mock repository methods with patch.object(restore, "_get_workflow_run_repo") as mock_get_repo: mock_repo = Mock() + mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute( + delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id) + ) mock_get_repo.return_value = mock_repo with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - result = restore._restore_from_run(run, session_maker=session_maker) + result = restore._restore_from_run(run, session_maker=database.session_maker) assert result.success is True assert result.restored_counts["workflow_runs"] == 1 - assert result.restored_counts["workflow_app_logs"] >= 1 # Just check it's restored - mock_session.commit.assert_called_once() - mock_repo.delete_archive_log_by_run_id.assert_called_once_with(mock_session, run.id) + assert result.restored_counts["workflow_app_logs"] == 2 + database.session.expire_all() + assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1 + assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 2 + assert database.session.scalar(select(func.count(WorkflowArchiveLog.id))) == 0 @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") - def test_invalid_archive_bundle(self, mock_get_storage): + def test_insert_failure_rolls_back_all_tables(self, mock_get_storage, database: Database): + """A later table failure must roll back earlier restored rows.""" + restore = WorkflowRunRestore() + mock_storage = Mock() + mock_storage.get_object.return_value = WorkflowRunRestoreTestDataFactory.create_archive_zip_mock() + mock_get_storage.return_value = mock_storage + run = WorkflowRunRestoreTestDataFactory.create_workflow_run() + + def fail_app_log_insert(_connection, _cursor, statement, _parameters, _context, _executemany): + if statement.startswith("INSERT INTO workflow_app_logs"): + raise RuntimeError("forced app-log insert failure") + + event.listen(database.engine, "before_cursor_execute", fail_app_log_insert) + try: + with patch("services.retention.workflow_run.restore_archived_workflow_run.click"): + result = restore._restore_from_run(run, session_maker=database.session_maker) + finally: + event.remove(database.engine, "before_cursor_execute", fail_app_log_insert) + + assert result.success is False + assert result.error == "forced app-log insert failure" + assert database.session.scalar(select(func.count(WorkflowRun.id))) == 0 + assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 0 + + @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") + def test_invalid_archive_bundle(self, mock_get_storage, database: Database): """Should handle invalid archive bundle.""" restore = WorkflowRunRestore() @@ -725,22 +745,17 @@ class TestRestoreFromRun: mock_storage.get_object.return_value = b"invalid zip data" mock_get_storage.return_value = mock_storage - run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock() - - # Create proper mock session - mock_session = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=None) + run = WorkflowRunRestoreTestDataFactory.create_workflow_run() with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - result = restore._restore_from_run(run, session_maker=lambda: mock_session) + result = restore._restore_from_run(run, session_maker=database.session_maker) assert result.success is False # The error message comes from zipfile.BadZipFile which says "File is not a zip file" assert "File is not a zip file" in result.error @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") - def test_workflow_archive_log_input(self, mock_get_storage): + def test_workflow_archive_log_input(self, mock_get_storage, database: Database): """Should handle WorkflowArchiveLog input correctly.""" restore = WorkflowRunRestore(dry_run=True) @@ -750,14 +765,11 @@ class TestRestoreFromRun: mock_storage.get_object.return_value = archive_data mock_get_storage.return_value = mock_storage - archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock() + archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log() + database.session.add(archive_log) + database.session.commit() - # Create proper mock session - mock_session = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=None) - - result = restore._restore_from_run(archive_log, session_maker=lambda: mock_session) + result = restore._restore_from_run(archive_log, session_maker=database.session_maker) assert result.success is True assert result.run_id == archive_log.workflow_run_id @@ -772,39 +784,29 @@ class TestRestoreFromRun: class TestRestoreBatch: """Tests for WorkflowRunRestore.restore_batch method.""" - @patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker") - def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker): + def test_empty_tenant_ids_returns_empty(self, database: Database): """Should return empty list when tenant_ids is empty list.""" restore = WorkflowRunRestore() - # Mock db.engine to avoid SQLAlchemy issues - with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db: - mock_db.engine = Mock() - result = restore.restore_batch( - tenant_ids=[], - start_date=datetime(2024, 1, 1), - end_date=datetime(2024, 1, 2), - ) + result = restore.restore_batch( + tenant_ids=[], + start_date=datetime(2024, 1, 1), + end_date=datetime(2024, 1, 2), + ) assert result == [] @patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor") - def test_successful_batch_restore(self, mock_executor): + def test_successful_batch_restore(self, mock_executor, database: Database): """Should successfully restore batch of workflow runs.""" restore = WorkflowRunRestore(workers=2) - # Mock session that supports context manager protocol - mock_session = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=None) - - # Mock session factory that returns context manager sessions - mock_session_factory = Mock(return_value=mock_session) - # Mock repository and archive logs mock_repo = Mock() - archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1") - archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2") + archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-1") + archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2") + database.session.add_all([archive_log1, archive_log2]) + database.session.commit() mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2] # Mock restore results @@ -821,38 +823,25 @@ class TestRestoreBatch: with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo): with patch.object(restore, "_restore_from_run", side_effect=[result1, result2]): with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - # Mock sessionmaker and db.engine to avoid SQLAlchemy issues - with patch( - "services.retention.workflow_run.restore_archived_workflow_run.sessionmaker" - ) as mock_sessionmaker: - mock_sessionmaker.return_value = mock_session_factory - with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db: - mock_db.engine = Mock() - results = restore.restore_batch( - tenant_ids=["tenant-1"], - start_date=datetime(2024, 1, 1), - end_date=datetime(2024, 1, 2), - ) + results = restore.restore_batch( + tenant_ids=["tenant-1"], + start_date=datetime(2024, 1, 1), + end_date=datetime(2024, 1, 2), + ) assert len(results) == 2 assert results[0].run_id == "run-1" assert results[1].run_id == "run-2" @patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor") - def test_dry_run_batch_restore(self, mock_executor): + def test_dry_run_batch_restore(self, mock_executor, database: Database): """Should handle dry run mode for batch restore.""" restore = WorkflowRunRestore(dry_run=True) - # Mock session that supports context manager protocol - mock_session = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=None) - - # Mock session factory that returns context manager sessions - mock_session_factory = Mock(return_value=mock_session) - mock_repo = Mock() - archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock() + archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log() + database.session.add(archive_log) + database.session.commit() mock_repo.get_archived_logs_by_time_range.return_value = [archive_log] result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1}) @@ -867,18 +856,11 @@ class TestRestoreBatch: with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo): with patch.object(restore, "_restore_from_run", return_value=result): with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - # Mock sessionmaker and db.engine to avoid SQLAlchemy issues - with patch( - "services.retention.workflow_run.restore_archived_workflow_run.sessionmaker" - ) as mock_sessionmaker: - mock_sessionmaker.return_value = mock_session_factory - with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db: - mock_db.engine = Mock() - results = restore.restore_batch( - tenant_ids=["tenant-1"], - start_date=datetime(2024, 1, 1), - end_date=datetime(2024, 1, 2), - ) + results = restore.restore_batch( + tenant_ids=["tenant-1"], + start_date=datetime(2024, 1, 1), + end_date=datetime(2024, 1, 2), + ) assert len(results) == 1 assert results[0].success is True @@ -907,16 +889,14 @@ class TestRestoreByRunId: assert "not found" in result.error assert result.run_id == "nonexistent-run" - @patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker") - def test_successful_restore_by_id(self, mock_sessionmaker): + def test_successful_restore_by_id(self, database: Database): """Should successfully restore by run ID.""" restore = WorkflowRunRestore() - mock_session = Mock() - mock_sessionmaker.return_value = mock_session - mock_repo = Mock() - archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock() + archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log() + database.session.add(archive_log) + database.session.commit() mock_repo.get_archived_log_by_run_id.return_value = archive_log result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={}) @@ -924,24 +904,19 @@ class TestRestoreByRunId: with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo): with patch.object(restore, "_restore_from_run", return_value=result): with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - # Mock db.engine to avoid SQLAlchemy issues - with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db: - mock_db.engine = Mock() - actual_result = restore.restore_by_run_id("run-1") + actual_result = restore.restore_by_run_id("run-1") assert actual_result.success is True assert actual_result.run_id == "run-1" - @patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker") - def test_dry_run_restore_by_id(self, mock_sessionmaker): + def test_dry_run_restore_by_id(self, database: Database): """Should handle dry run mode for restore by ID.""" restore = WorkflowRunRestore(dry_run=True) - mock_session = Mock() - mock_sessionmaker.return_value = mock_session - mock_repo = Mock() - archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock() + archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log() + database.session.add(archive_log) + database.session.commit() mock_repo.get_archived_log_by_run_id.return_value = archive_log result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1}) @@ -949,10 +924,7 @@ class TestRestoreByRunId: with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo): with patch.object(restore, "_restore_from_run", return_value=result): with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - # Mock db.engine to avoid SQLAlchemy issues - with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db: - mock_db.engine = Mock() - actual_result = restore.restore_by_run_id("run-1") + actual_result = restore.restore_by_run_id("run-1") assert actual_result.success is True assert actual_result.run_id == "run-1" @@ -1038,8 +1010,7 @@ class TestIntegration: """Integration tests combining multiple components.""" @patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage") - @patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor") - def test_full_restore_flow(self, mock_executor, mock_get_storage): + def test_full_restore_flow(self, mock_get_storage, database: Database): """Test complete restore flow with all components.""" restore = WorkflowRunRestore(workers=1) @@ -1059,7 +1030,7 @@ class TestIntegration: "app_id": "app-123", "workflow_id": "workflow-123", "type": "workflow", - "triggered_from": "app", + "triggered_from": "app-run", "version": "1", "status": "succeeded", "created_by_role": "account", @@ -1072,48 +1043,20 @@ class TestIntegration: mock_storage.get_object.return_value = archive_data mock_get_storage.return_value = mock_storage - # Mock session that supports context manager protocol - mock_session = Mock() - mock_session.__enter__ = Mock(return_value=mock_session) - mock_session.__exit__ = Mock(return_value=None) - - # Mock session factory that returns context manager sessions - mock_session_factory = Mock(return_value=mock_session) - - mock_result = Mock() - mock_result.rowcount = 1 - mock_session.execute.return_value = mock_result - # Mock repository mock_repo = Mock() - archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock() + archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log() + database.session.add(archive_log) + database.session.commit() mock_repo.get_archived_log_by_run_id.return_value = archive_log - - # Mock ThreadPoolExecutor (not actually used in restore_by_run_id but needed for patch) - mock_executor_instance = Mock() - mock_executor_instance.__enter__ = Mock(return_value=mock_executor_instance) - mock_executor_instance.__exit__ = Mock(return_value=None) - mock_executor_instance.map = Mock(return_value=[]) - mock_executor.return_value = mock_executor_instance + mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute( + delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id) + ) with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo): - with patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") as mock_insert: - mock_stmt = Mock() - mock_stmt.on_conflict_do_nothing.return_value = mock_stmt - mock_insert.return_value = mock_stmt - - with patch("services.retention.workflow_run.restore_archived_workflow_run.cast") as mock_cast: - mock_cast.return_value = mock_result - - with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click: - # Mock sessionmaker and db.engine to avoid SQLAlchemy issues - with patch( - "services.retention.workflow_run.restore_archived_workflow_run.sessionmaker" - ) as mock_sessionmaker: - mock_sessionmaker.return_value = mock_session_factory - with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db: - mock_db.engine = Mock() - result = restore.restore_by_run_id("run-123") + with patch("services.retention.workflow_run.restore_archived_workflow_run.click"): + result = restore.restore_by_run_id("run-123") assert result.success is True assert result.restored_counts.get("workflow_runs") == 1 + assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1 From 63f072ebfbb6fc681a8d9c70a81dfc8f08dd9374 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:47:22 +0900 Subject: [PATCH 089/531] test: use SQLite sessions in core rag (#39105) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../rag/extractor/test_excel_extractor.py | 134 +++++++----------- 1 file changed, 51 insertions(+), 83 deletions(-) diff --git a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py index ebe24c29007..af12b8780f6 100644 --- a/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py +++ b/api/tests/unit_tests/core/rag/extractor/test_excel_extractor.py @@ -2,9 +2,21 @@ from types import SimpleNamespace import pandas as pd import pytest +from sqlalchemy import Engine, select +from sqlalchemy.orm import Session, sessionmaker import core.rag.extractor.excel_extractor as excel_module from core.rag.extractor.excel_extractor import ExcelExtractor +from models.base import TypeBase +from models.model import UploadFile + + +@pytest.fixture +def database_session_maker(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> sessionmaker[Session]: + TypeBase.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__]) + session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) + monkeypatch.setattr(excel_module.session_factory, "create_session", session_maker) + return session_maker class _FakeCell: @@ -58,82 +70,22 @@ class _FakeImage: return self._raw_data -class _FieldExpression: - def __eq__(self, other): - return ("eq", other) - - def in_(self, values): - return ("in", tuple(values)) - - -class _SelectStub: - def where(self, *args, **kwargs): - return self - - -class _FakeUploadFile: - tenant_id = _FieldExpression() - key = _FieldExpression() - _i = 0 - - def __init__(self, **kwargs): - type(self)._i += 1 - self.id = f"u{self._i}" - self.key = kwargs["key"] - - -class _PersistentSession: - def __init__(self, persisted): - self._persisted = persisted - self.added = [] - self.commit_count = 0 - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalars(self, _stmt): - return SimpleNamespace(all=lambda: list(self._persisted.values())) - - def add_all(self, objects) -> None: - self.added.extend(objects) - - def commit(self) -> None: - self.commit_count += 1 - for upload_file in self.added: - self._persisted[upload_file.key] = upload_file - self.added.clear() - - -class _PersistentSessionFactory: - def __init__(self): - self.persisted = {} - self.sessions = [] - - def create_session(self): - session = _PersistentSession(self.persisted) - self.sessions.append(session) - return session - - def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch): saves: list[tuple[str, bytes]] = [] - session_factory = _PersistentSessionFactory() def save(key: str, data: bytes) -> None: saves.append((key, data)) - _FakeUploadFile._i = 0 - monkeypatch.setattr(excel_module, "storage", SimpleNamespace(save=save)) - monkeypatch.setattr(excel_module, "session_factory", session_factory) - monkeypatch.setattr(excel_module, "select", lambda *args, **kwargs: _SelectStub()) - monkeypatch.setattr(excel_module, "UploadFile", _FakeUploadFile) + monkeypatch.setattr(excel_module.storage, "save", save) monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False) monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False) - return saves, session_factory + return saves + + +def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]: + with session_maker() as session: + return list(session.scalars(select(UploadFile)).all()) class TestExcelExtractor: @@ -160,7 +112,11 @@ class TestExcelExtractor: assert docs[1].page_content == '"Name":"";"Link":"123"' assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs) - def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch): + def test_extract_xlsx_turns_embedded_images_into_markdown_links( + self, + monkeypatch: pytest.MonkeyPatch, + database_session_maker: sessionmaker[Session], + ): image_bytes = b"\x89PNG\r\n\x1a\nexcel-image" sheet = _FakeSheet( header_rows=[("Question", "Answer", "Image")], @@ -175,7 +131,7 @@ class TestExcelExtractor: ) workbook = _FakeWorkbook({"Data": sheet}) monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook) - saves, session_factory = _patch_image_persistence(monkeypatch) + saves = _patch_image_persistence(monkeypatch) extractor = ExcelExtractor( "/tmp/sample.xlsx", @@ -184,23 +140,30 @@ class TestExcelExtractor: source_file_id="source-file-1", ) docs = extractor.extract() + upload_files = _get_upload_files(database_session_maker) assert workbook.closed is True assert len(docs) == 2 + assert len(upload_files) == 1 assert docs[0].page_content == ( '"Question":"Q1";"Answer":"A1";' - '"Image":"![image](http://files.local/files/u1/file-preview) ' - '![image](http://files.local/files/u1/file-preview)"' + f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview) ' + f'![image](http://files.local/files/{upload_files[0].id}/file-preview)"' ) assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""' assert len(saves) == 1 assert saves[0][0].startswith("image_files/tenant-1/source-file-1/") assert saves[0][0].endswith(".png") assert saves[0][1] == image_bytes - assert len(session_factory.persisted) == 1 - assert [session.commit_count for session in session_factory.sessions] == [1] + assert upload_files[0].tenant_id == "tenant-1" + assert upload_files[0].key == saves[0][0] + assert upload_files[0].used is True - def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch): + def test_extract_xlsx_keeps_rows_with_only_embedded_images( + self, + monkeypatch: pytest.MonkeyPatch, + database_session_maker: sessionmaker[Session], + ): image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row" sheet = _FakeSheet( header_rows=[("Question", "Answer", "Image")], @@ -212,7 +175,7 @@ class TestExcelExtractor: ) workbook = _FakeWorkbook({"Data": sheet}) monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook) - saves, session_factory = _patch_image_persistence(monkeypatch) + saves = _patch_image_persistence(monkeypatch) extractor = ExcelExtractor( "/tmp/sample.xlsx", @@ -221,17 +184,21 @@ class TestExcelExtractor: source_file_id="source-file-1", ) docs = extractor.extract() + upload_files = _get_upload_files(database_session_maker) assert workbook.closed is True assert len(docs) == 1 + assert len(upload_files) == 1 assert docs[0].page_content == ( - '"Question":"";"Answer":"";"Image":"![image](http://files.local/files/u1/file-preview)"' + f'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"' ) assert len(saves) == 1 - assert len(session_factory.persisted) == 1 - assert [session.commit_count for session in session_factory.sessions] == [1] - def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch): + def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry( + self, + monkeypatch: pytest.MonkeyPatch, + database_session_maker: sessionmaker[Session], + ): image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image" workbooks = [ _FakeWorkbook( @@ -254,7 +221,7 @@ class TestExcelExtractor: ), ] monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0)) - saves, session_factory = _patch_image_persistence(monkeypatch) + saves = _patch_image_persistence(monkeypatch) extractor = ExcelExtractor( "/tmp/sample.xlsx", @@ -264,16 +231,17 @@ class TestExcelExtractor: ) first_docs = extractor.extract() second_docs = extractor.extract() + upload_files = _get_upload_files(database_session_maker) + assert len(upload_files) == 1 expected_page_content = ( - '"Question":"Q1";"Answer":"A1";"Image":"![image](http://files.local/files/u1/file-preview)"' + '"Question":"Q1";"Answer":"A1";' + f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"' ) assert first_docs[0].page_content == expected_page_content assert second_docs[0].page_content == expected_page_content assert len(saves) == 1 - assert len(session_factory.persisted) == 1 - assert [session.commit_count for session in session_factory.sessions] == [1, 0] def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch): class FakeExcelFile: From 59fb603ec61ae7f58afd7df192ab4ffe628bcf8a Mon Sep 17 00:00:00 2001 From: Harsh Kashyap Date: Tue, 28 Jul 2026 11:19:19 +0530 Subject: [PATCH 090/531] fix: reject trailing newlines in alphanumeric() validator (#39666) (#39667) Co-authored-by: Harsh Kashyap --- api/libs/helper.py | 6 +++- api/tests/unit_tests/libs/test_helper.py | 46 +++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/api/libs/helper.py b/api/libs/helper.py index 752342bfad6..0992553442a 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -289,7 +289,11 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)] def alphanumeric(value: str): # check if the value is alphanumeric and underlined - if re.match(r"^[a-zA-Z0-9_]+$", value): + # Use re.fullmatch instead of re.match to reject trailing newlines. + # In Python, '$' matches at end-of-string OR just before a trailing newline, + # so re.match accepts "tool_name\n". re.fullmatch requires the entire + # string to match. Regression for #39666 (sibling of #39234 / #39548). + if re.fullmatch(r"^[a-zA-Z0-9_]+$", value): return value raise ValueError(f"{value} is not a valid alphanumeric value") diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py index dbc2cd6cba8..d5f78e67abc 100644 --- a/api/tests/unit_tests/libs/test_helper.py +++ b/api/tests/unit_tests/libs/test_helper.py @@ -2,7 +2,7 @@ from datetime import datetime import pytest -from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id +from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id from models.account import Account from models.model import EndUser @@ -153,3 +153,47 @@ class TestEmailValidator: def test_invalid_email_rejected(self): with pytest.raises(ValueError, match="not a valid email"): email("not-an-email") + + +class TestAlphanumericValidator: + """Tests for the alphanumeric() validator — regression for #39666.""" + + def test_valid_alphanumeric_accepted(self): + assert alphanumeric("tool_name") == "tool_name" + assert alphanumeric("Tool123") == "Tool123" + assert alphanumeric("_underscore_start") == "_underscore_start" + assert alphanumeric("a") == "a" + + def test_trailing_newline_rejected(self): + # re.match with $ accepts a trailing \n in Python; re.fullmatch does not. + # This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n". + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool_name\n") + + def test_trailing_carriage_return_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool_name\r") + + def test_trailing_crlf_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool_name\r\n") + + def test_leading_newline_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("\ntool_name") + + def test_embedded_whitespace_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool name") + + def test_empty_string_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("") + + def test_special_characters_rejected(self): + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool-name") + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool.name") + with pytest.raises(ValueError, match="not a valid alphanumeric value"): + alphanumeric("tool/name") From e723b348cf9cf412f0e9ffc1a20abdf56fe23bff Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 14:54:17 +0900 Subject: [PATCH 091/531] test: use SQLite sessions in core app apps (#39103) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../test_generate_task_pipeline_core.py | 106 ++++++++---------- 1 file changed, 49 insertions(+), 57 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index 04fe7a2ebed..9f83e657cfb 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -1,11 +1,11 @@ from __future__ import annotations import logging -from contextlib import contextmanager from types import SimpleNamespace -from unittest.mock import MagicMock import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline @@ -54,6 +54,7 @@ from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole from models.model import AppMode, EndUser +from models.workflow import WorkflowAppLog from tests.workflow_test_utils import build_test_variable_pool @@ -193,7 +194,7 @@ class TestWorkflowGenerateTaskPipeline: assert isinstance(responses[0], ValueError) - def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch): + def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine): pipeline = _make_pipeline() pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")), @@ -201,11 +202,10 @@ class TestWorkflowGenerateTaskPipeline: ) pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started" - @contextmanager - def _fake_session(): - yield SimpleNamespace() - - monkeypatch.setattr(pipeline, "_database_session", _fake_session) + monkeypatch.setattr( + "core.app.apps.workflow.generate_task_pipeline.db", + SimpleNamespace(engine=sqlite_engine), + ) monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None) responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent())) @@ -339,19 +339,18 @@ class TestWorkflowGenerateTaskPipeline: assert responses == ["finish"] - def test_save_workflow_app_log_created_from(self): + @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) + def test_save_workflow_app_log_created_from(self, sqlite_session: Session): pipeline = _make_pipeline() pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API pipeline._user_id = "user" - added: list[object] = [] + pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id") + sqlite_session.flush() - class _Session: - def add(self, item): - added.append(item) - - pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id") - - assert added + saved_log = sqlite_session.scalar(select(WorkflowAppLog)) + assert saved_log is not None + assert saved_log.workflow_run_id == "run-id" + assert saved_log.created_from == "service-api" def test_iteration_loop_and_human_input_handlers(self): pipeline = _make_pipeline() @@ -674,35 +673,29 @@ class TestWorkflowGenerateTaskPipeline: assert "Fails to get audio trunk, task_id: task" in caplog.messages assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses) - def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch): + @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) + def test_database_session_rolls_back_on_error( + self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session + ): pipeline = _make_pipeline() - calls = {"enter": 0, "exit_exc": None} + pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API + pipeline._user_id = "user" + monkeypatch.setattr( + "core.app.apps.workflow.generate_task_pipeline.db", + SimpleNamespace(engine=sqlite_engine), + ) - class _BeginContext: - def __enter__(self): - calls["enter"] += 1 - return MagicMock() - - def __exit__(self, exc_type, exc, tb): - calls["exit_exc"] = exc_type - return False - - class _Sessionmaker: - def __init__(self, *args, **kwargs): - pass - - def begin(self): - return _BeginContext() - - monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker) - monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object())) - - with pytest.raises(RuntimeError, match="db error"): - with pipeline._database_session(): + def persist_then_fail() -> None: + with pipeline._database_session() as session: + pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id") + session.flush() raise RuntimeError("db error") - assert calls["enter"] == 1 - assert calls["exit_exc"] is RuntimeError + with pytest.raises(RuntimeError, match="db error"): + persist_then_fail() + + sqlite_session.expire_all() + assert sqlite_session.scalar(select(WorkflowAppLog)) is None def test_node_retry_and_started_handlers_cover_none_and_value(self): pipeline = _make_pipeline() @@ -862,31 +855,30 @@ class TestWorkflowGenerateTaskPipeline: pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"]) assert list(pipeline._process_stream_response()) == ["stopped"] - def test_save_workflow_app_log_covers_invoke_from_variants(self): + @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) + def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session): pipeline = _make_pipeline() pipeline._user_id = "user-id" - added: list[object] = [] - - class _Session: - def add(self, item): - added.append(item) pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE - pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id") - assert added[-1].created_from == "installed-app" + pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id") pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP - pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id") - assert added[-1].created_from == "web-app" + pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2") + sqlite_session.flush() + saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all() + assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"] - count_before = len(added) + count_before = len(saved_logs) pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER - pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id") - assert len(added) == count_before + pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3") + sqlite_session.flush() + assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP - pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None) - assert len(added) == count_before + pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None) + sqlite_session.flush() + assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before def test_save_output_for_event_writes_draft_variables(self): pipeline = _make_pipeline() From 137d4f3f6008c5fa232e3bb78efbd393e1a5e0cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:11:36 +0000 Subject: [PATCH 092/531] chore(i18n): sync translations with en-US (#39671) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/app-log.json | 2 +- web/i18n/de-DE/app-log.json | 2 +- web/i18n/es-ES/app-log.json | 2 +- web/i18n/fa-IR/app-log.json | 2 +- web/i18n/fr-FR/app-log.json | 2 +- web/i18n/hi-IN/app-log.json | 2 +- web/i18n/id-ID/app-log.json | 2 +- web/i18n/it-IT/app-log.json | 2 +- web/i18n/ja-JP/app-log.json | 2 +- web/i18n/ko-KR/app-log.json | 2 +- web/i18n/nl-NL/app-log.json | 2 +- web/i18n/pl-PL/app-log.json | 2 +- web/i18n/pt-BR/app-log.json | 2 +- web/i18n/ro-RO/app-log.json | 2 +- web/i18n/ru-RU/app-log.json | 2 +- web/i18n/sl-SI/app-log.json | 2 +- web/i18n/th-TH/app-log.json | 2 +- web/i18n/tr-TR/app-log.json | 2 +- web/i18n/uk-UA/app-log.json | 2 +- web/i18n/vi-VN/app-log.json | 2 +- web/i18n/zh-Hans/app-log.json | 2 +- web/i18n/zh-Hant/app-log.json | 2 +- 22 files changed, 22 insertions(+), 22 deletions(-) diff --git a/web/i18n/ar-TN/app-log.json b/web/i18n/ar-TN/app-log.json index d8cf13af28e..15c5ec30fa8 100644 --- a/web/i18n/ar-TN/app-log.json +++ b/web/i18n/ar-TN/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "لا توجد سجلات مؤرشفة", "archives.error.description": "حدّث الصفحة أو حاول مرة أخرى لاحقًا.", "archives.error.title": "تعذّر تحميل السجلات المؤرشفة", - "archives.notice.action": "عرض السجلات المؤرشفة", + "archives.notice.action": "فتح السجلات المؤرشفة", "archives.notice.description": "قد تكون بعض السجلات ضمن هذا النطاق الزمني قد أُرشفت.", "archives.summary.latest": "أحدث أرشيف", "archives.summary.months": "الأشهر المؤرشفة", diff --git a/web/i18n/de-DE/app-log.json b/web/i18n/de-DE/app-log.json index 8c2e5adbdc2..cc05e93dd12 100644 --- a/web/i18n/de-DE/app-log.json +++ b/web/i18n/de-DE/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Keine archivierten Protokolle", "archives.error.description": "Aktualisieren Sie die Seite oder versuchen Sie es später erneut.", "archives.error.title": "Archivierte Protokolle konnten nicht geladen werden", - "archives.notice.action": "Archivierte Protokolle anzeigen", + "archives.notice.action": "Archivierte Protokolle öffnen", "archives.notice.description": "Einige Protokolle in diesem Zeitraum wurden möglicherweise archiviert.", "archives.summary.latest": "Neueste Archivierung", "archives.summary.months": "Archivierte Monate", diff --git a/web/i18n/es-ES/app-log.json b/web/i18n/es-ES/app-log.json index 1a6abe0e6b4..bdebe5e2a83 100644 --- a/web/i18n/es-ES/app-log.json +++ b/web/i18n/es-ES/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "No hay registros archivados", "archives.error.description": "Actualiza la página o inténtalo de nuevo más tarde.", "archives.error.title": "No se pudieron cargar los registros archivados", - "archives.notice.action": "Ver registros archivados", + "archives.notice.action": "Abrir registros archivados", "archives.notice.description": "Es posible que algunos registros de este intervalo de tiempo se hayan archivado.", "archives.summary.latest": "Último archivo", "archives.summary.months": "Meses archivados", diff --git a/web/i18n/fa-IR/app-log.json b/web/i18n/fa-IR/app-log.json index 1e7a8db1b22..726d38fa4b2 100644 --- a/web/i18n/fa-IR/app-log.json +++ b/web/i18n/fa-IR/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "لاگ بایگانی‌شده‌ای وجود ندارد", "archives.error.description": "صفحه را تازه‌سازی کنید یا بعداً دوباره تلاش کنید.", "archives.error.title": "امکان بارگیری لاگ‌های بایگانی‌شده وجود ندارد", - "archives.notice.action": "مشاهده لاگ‌های بایگانی‌شده", + "archives.notice.action": "باز کردن لاگ‌های بایگانی‌شده", "archives.notice.description": "ممکن است برخی لاگ‌ها در این بازه زمانی بایگانی شده باشند.", "archives.summary.latest": "آخرین بایگانی", "archives.summary.months": "ماه‌های بایگانی‌شده", diff --git a/web/i18n/fr-FR/app-log.json b/web/i18n/fr-FR/app-log.json index 51bc30fd933..492e687d1d8 100644 --- a/web/i18n/fr-FR/app-log.json +++ b/web/i18n/fr-FR/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Aucun journal archivé", "archives.error.description": "Actualisez la page ou réessayez plus tard.", "archives.error.title": "Impossible de charger les journaux archivés", - "archives.notice.action": "Voir les journaux archivés", + "archives.notice.action": "Ouvrir les journaux archivés", "archives.notice.description": "Certains journaux de cette période peuvent avoir été archivés.", "archives.summary.latest": "Dernière archive", "archives.summary.months": "Mois archivés", diff --git a/web/i18n/hi-IN/app-log.json b/web/i18n/hi-IN/app-log.json index 47adae71c94..abe351a4db1 100644 --- a/web/i18n/hi-IN/app-log.json +++ b/web/i18n/hi-IN/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "कोई आर्काइव लॉग नहीं", "archives.error.description": "पेज रीफ्रेश करें या बाद में फिर से प्रयास करें।", "archives.error.title": "आर्काइव लॉग लोड नहीं हो सके", - "archives.notice.action": "आर्काइव लॉग देखें", + "archives.notice.action": "आर्काइव लॉग खोलें", "archives.notice.description": "इस समय सीमा के कुछ लॉग आर्काइव किए गए हो सकते हैं।", "archives.summary.latest": "नवीनतम आर्काइव", "archives.summary.months": "आर्काइव किए गए महीने", diff --git a/web/i18n/id-ID/app-log.json b/web/i18n/id-ID/app-log.json index c0aff59edca..952666f5efa 100644 --- a/web/i18n/id-ID/app-log.json +++ b/web/i18n/id-ID/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Tidak ada log yang diarsipkan", "archives.error.description": "Muat ulang halaman atau coba lagi nanti.", "archives.error.title": "Tidak dapat memuat log yang diarsipkan", - "archives.notice.action": "Lihat log yang diarsipkan", + "archives.notice.action": "Buka log yang diarsipkan", "archives.notice.description": "Beberapa log dalam rentang waktu ini mungkin telah diarsipkan.", "archives.summary.latest": "Arsip terbaru", "archives.summary.months": "Bulan yang diarsipkan", diff --git a/web/i18n/it-IT/app-log.json b/web/i18n/it-IT/app-log.json index d2e7b497a05..2f8d248e54e 100644 --- a/web/i18n/it-IT/app-log.json +++ b/web/i18n/it-IT/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Nessun log archiviato", "archives.error.description": "Aggiorna la pagina o riprova più tardi.", "archives.error.title": "Impossibile caricare i log archiviati", - "archives.notice.action": "Visualizza log archiviati", + "archives.notice.action": "Apri log archiviati", "archives.notice.description": "Alcuni log in questo intervallo di tempo potrebbero essere stati archiviati.", "archives.summary.latest": "Archivio più recente", "archives.summary.months": "Mesi archiviati", diff --git a/web/i18n/ja-JP/app-log.json b/web/i18n/ja-JP/app-log.json index 25b49eeea13..80f540b2405 100644 --- a/web/i18n/ja-JP/app-log.json +++ b/web/i18n/ja-JP/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "アーカイブされたログはありません", "archives.error.description": "ページを更新するか、後でもう一度お試しください。", "archives.error.title": "アーカイブされたログを読み込めませんでした", - "archives.notice.action": "アーカイブされたログを表示", + "archives.notice.action": "アーカイブされたログを開く", "archives.notice.description": "この期間の一部のログはアーカイブされている可能性があります。", "archives.summary.latest": "最新のアーカイブ", "archives.summary.months": "アーカイブ済みの月", diff --git a/web/i18n/ko-KR/app-log.json b/web/i18n/ko-KR/app-log.json index 9a3f8241fcb..22317455715 100644 --- a/web/i18n/ko-KR/app-log.json +++ b/web/i18n/ko-KR/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "보관된 로그가 없습니다", "archives.error.description": "페이지를 새로 고치거나 나중에 다시 시도하세요.", "archives.error.title": "보관된 로그를 불러올 수 없습니다", - "archives.notice.action": "보관된 로그 보기", + "archives.notice.action": "보관된 로그 열기", "archives.notice.description": "이 시간 범위의 일부 로그가 보관되었을 수 있습니다.", "archives.summary.latest": "최신 아카이브", "archives.summary.months": "보관된 월", diff --git a/web/i18n/nl-NL/app-log.json b/web/i18n/nl-NL/app-log.json index a63f93b7491..0e832564ed1 100644 --- a/web/i18n/nl-NL/app-log.json +++ b/web/i18n/nl-NL/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Geen gearchiveerde logs", "archives.error.description": "Vernieuw de pagina of probeer het later opnieuw.", "archives.error.title": "Kan gearchiveerde logs niet laden", - "archives.notice.action": "Gearchiveerde logs bekijken", + "archives.notice.action": "Gearchiveerde logs openen", "archives.notice.description": "Sommige logs in deze periode zijn mogelijk gearchiveerd.", "archives.summary.latest": "Nieuwste archief", "archives.summary.months": "Gearchiveerde maanden", diff --git a/web/i18n/pl-PL/app-log.json b/web/i18n/pl-PL/app-log.json index 8ec7c60945c..494d623bbf2 100644 --- a/web/i18n/pl-PL/app-log.json +++ b/web/i18n/pl-PL/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Brak zarchiwizowanych logów", "archives.error.description": "Odśwież stronę lub spróbuj ponownie później.", "archives.error.title": "Nie można załadować zarchiwizowanych logów", - "archives.notice.action": "Wyświetl zarchiwizowane logi", + "archives.notice.action": "Otwórz zarchiwizowane logi", "archives.notice.description": "Niektóre logi z tego zakresu czasu mogły zostać zarchiwizowane.", "archives.summary.latest": "Najnowsze archiwum", "archives.summary.months": "Zarchiwizowane miesiące", diff --git a/web/i18n/pt-BR/app-log.json b/web/i18n/pt-BR/app-log.json index 41875ffa780..4167ca1f59b 100644 --- a/web/i18n/pt-BR/app-log.json +++ b/web/i18n/pt-BR/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Nenhum log arquivado", "archives.error.description": "Atualize a página ou tente novamente mais tarde.", "archives.error.title": "Não foi possível carregar os logs arquivados", - "archives.notice.action": "Ver logs arquivados", + "archives.notice.action": "Abrir logs arquivados", "archives.notice.description": "Alguns logs neste intervalo de tempo podem ter sido arquivados.", "archives.summary.latest": "Arquivo mais recente", "archives.summary.months": "Meses arquivados", diff --git a/web/i18n/ro-RO/app-log.json b/web/i18n/ro-RO/app-log.json index 300d3ac5692..e38fb8dbfa5 100644 --- a/web/i18n/ro-RO/app-log.json +++ b/web/i18n/ro-RO/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Nu există jurnale arhivate", "archives.error.description": "Reîmprospătează pagina sau încearcă din nou mai târziu.", "archives.error.title": "Jurnalele arhivate nu au putut fi încărcate", - "archives.notice.action": "Vezi jurnalele arhivate", + "archives.notice.action": "Deschide jurnalele arhivate", "archives.notice.description": "Este posibil ca unele jurnale din acest interval de timp să fi fost arhivate.", "archives.summary.latest": "Cea mai recentă arhivă", "archives.summary.months": "Luni arhivate", diff --git a/web/i18n/ru-RU/app-log.json b/web/i18n/ru-RU/app-log.json index 65a13026eed..82b03c4d616 100644 --- a/web/i18n/ru-RU/app-log.json +++ b/web/i18n/ru-RU/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Нет архивных логов", "archives.error.description": "Обновите страницу или повторите попытку позже.", "archives.error.title": "Не удалось загрузить архивные логи", - "archives.notice.action": "Посмотреть архивные логи", + "archives.notice.action": "Открыть архивные логи", "archives.notice.description": "Некоторые логи в этом временном диапазоне могли быть заархивированы.", "archives.summary.latest": "Последний архив", "archives.summary.months": "Архивные месяцы", diff --git a/web/i18n/sl-SI/app-log.json b/web/i18n/sl-SI/app-log.json index 655954dd58f..7cd40847bca 100644 --- a/web/i18n/sl-SI/app-log.json +++ b/web/i18n/sl-SI/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Ni arhiviranih dnevnikov", "archives.error.description": "Osvežite stran ali poskusite znova pozneje.", "archives.error.title": "Arhiviranih dnevnikov ni bilo mogoče naložiti", - "archives.notice.action": "Prikaži arhivirane dnevnike", + "archives.notice.action": "Odpri arhivirane dnevnike", "archives.notice.description": "Nekateri dnevniki v tem časovnem obdobju so bili morda arhivirani.", "archives.summary.latest": "Najnovejši arhiv", "archives.summary.months": "Arhivirani meseci", diff --git a/web/i18n/th-TH/app-log.json b/web/i18n/th-TH/app-log.json index 129c21717f6..d291231ccd7 100644 --- a/web/i18n/th-TH/app-log.json +++ b/web/i18n/th-TH/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "ไม่มีบันทึกที่เก็บถาวร", "archives.error.description": "รีเฟรชหน้า หรือลองอีกครั้งในภายหลัง", "archives.error.title": "ไม่สามารถโหลดบันทึกที่เก็บถาวรได้", - "archives.notice.action": "ดูบันทึกที่เก็บถาวร", + "archives.notice.action": "เปิดบันทึกที่เก็บถาวร", "archives.notice.description": "บันทึกบางส่วนในช่วงเวลานี้อาจถูกเก็บถาวรแล้ว", "archives.summary.latest": "ไฟล์เก็บถาวรล่าสุด", "archives.summary.months": "เดือนที่เก็บถาวร", diff --git a/web/i18n/tr-TR/app-log.json b/web/i18n/tr-TR/app-log.json index 522fb0b41fa..82322b4d925 100644 --- a/web/i18n/tr-TR/app-log.json +++ b/web/i18n/tr-TR/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Arşivlenmiş günlük yok", "archives.error.description": "Sayfayı yenileyin veya daha sonra tekrar deneyin.", "archives.error.title": "Arşivlenmiş günlükler yüklenemedi", - "archives.notice.action": "Arşivlenmiş günlükleri görüntüle", + "archives.notice.action": "Arşivlenmiş günlükleri aç", "archives.notice.description": "Bu zaman aralığındaki bazı günlükler arşivlenmiş olabilir.", "archives.summary.latest": "En son arşiv", "archives.summary.months": "Arşivlenen aylar", diff --git a/web/i18n/uk-UA/app-log.json b/web/i18n/uk-UA/app-log.json index 3f93908c563..1a6af17feb6 100644 --- a/web/i18n/uk-UA/app-log.json +++ b/web/i18n/uk-UA/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Немає архівних логів", "archives.error.description": "Оновіть сторінку або спробуйте ще раз пізніше.", "archives.error.title": "Не вдалося завантажити архівні логи", - "archives.notice.action": "Переглянути архівні логи", + "archives.notice.action": "Відкрити архівні логи", "archives.notice.description": "Деякі логи в цьому часовому діапазоні могли бути заархівовані.", "archives.summary.latest": "Останній архів", "archives.summary.months": "Архівні місяці", diff --git a/web/i18n/vi-VN/app-log.json b/web/i18n/vi-VN/app-log.json index 7cc86088e69..799f8fb7868 100644 --- a/web/i18n/vi-VN/app-log.json +++ b/web/i18n/vi-VN/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "Không có log đã lưu trữ", "archives.error.description": "Làm mới trang hoặc thử lại sau.", "archives.error.title": "Không thể tải log đã lưu trữ", - "archives.notice.action": "Xem log đã lưu trữ", + "archives.notice.action": "Mở log đã lưu trữ", "archives.notice.description": "Một số log trong khoảng thời gian này có thể đã được lưu trữ.", "archives.summary.latest": "Bản lưu trữ mới nhất", "archives.summary.months": "Tháng đã lưu trữ", diff --git a/web/i18n/zh-Hans/app-log.json b/web/i18n/zh-Hans/app-log.json index 38c57b9497e..90bdb4a7567 100644 --- a/web/i18n/zh-Hans/app-log.json +++ b/web/i18n/zh-Hans/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "暂无归档日志", "archives.error.description": "请刷新页面或稍后重试。", "archives.error.title": "无法加载归档日志", - "archives.notice.action": "查看归档日志", + "archives.notice.action": "打开归档日志", "archives.notice.description": "此时间范围内的部分日志可能已被归档。", "archives.summary.latest": "最新归档", "archives.summary.months": "归档月份", diff --git a/web/i18n/zh-Hant/app-log.json b/web/i18n/zh-Hant/app-log.json index 59e1633f5f4..6aba8383df6 100644 --- a/web/i18n/zh-Hant/app-log.json +++ b/web/i18n/zh-Hant/app-log.json @@ -21,7 +21,7 @@ "archives.empty.title": "沒有封存記錄", "archives.error.description": "請重新整理頁面或稍後再試。", "archives.error.title": "無法載入封存記錄", - "archives.notice.action": "查看封存記錄", + "archives.notice.action": "開啟封存記錄", "archives.notice.description": "此時間範圍內的部分記錄可能已被封存。", "archives.summary.latest": "最新封存", "archives.summary.months": "封存月份", From 9300be03f0fd5049319c9aabfa694aa0064bc1ee Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 15:32:07 +0900 Subject: [PATCH 093/531] test: use sqlite3 session in test_agent_app_feature_service (#38724) --- .../test_agent_app_feature_service.py | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/api/tests/unit_tests/services/test_agent_app_feature_service.py b/api/tests/unit_tests/services/test_agent_app_feature_service.py index 3d9337d79fb..c638aa2efaa 100644 --- a/api/tests/unit_tests/services/test_agent_app_feature_service.py +++ b/api/tests/unit_tests/services/test_agent_app_feature_service.py @@ -7,14 +7,16 @@ update_features persists those flags as a new app_model_config version without touching model / prompt / agent_mode. """ -from types import SimpleNamespace -from typing import Any - import pytest +from sqlalchemy.orm import Session +from models.account import Account +from models.model import App, AppMode, AppModelConfig from services.agent_app_feature_service import AgentAppFeatureConfigService TENANT_ID = "11111111-1111-1111-1111-111111111111" +APP_ID = "22222222-2222-2222-2222-222222222222" +ACCOUNT_ID = "33333333-3333-3333-3333-333333333333" class TestValidateFeatures: @@ -71,45 +73,47 @@ class TestValidateFeatures: AgentAppFeatureConfigService.validate_features(TENANT_ID, {"suggested_questions": "nope"}) -class _FakeWriteSession: - def __init__(self) -> None: - self.added: list[Any] = [] - self.flushed = 0 - self.committed = 0 - - def add(self, obj: Any) -> None: - self.added.append(obj) - - def flush(self) -> None: - self.flushed += 1 - - def commit(self) -> None: - self.committed += 1 - - class TestUpdateFeatures: - def test_persists_new_app_model_config_version(self): - session = _FakeWriteSession() - app_model = SimpleNamespace( - tenant_id=TENANT_ID, id="app-1", app_model_config_id=None, updated_by=None, updated_at=None + @pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True) + def test_persists_new_app_model_config_version(self, sqlite_session: Session): + app_model = App( + id=APP_ID, + tenant_id=TENANT_ID, + name="Agent App", + description="", + mode=AppMode.AGENT, + enable_site=True, + enable_api=True, + max_active_requests=0, ) - account = SimpleNamespace(id="acct-1") + account = Account(name="Test User", email="test@example.com") + account.id = ACCOUNT_ID + sqlite_session.add_all([account, app_model]) + sqlite_session.commit() new_config = AgentAppFeatureConfigService.update_features( - app_model=app_model, # type: ignore[arg-type] - account=account, # type: ignore[arg-type] + app_model=app_model, + account=account, config={"opening_statement": "Hi!", "suggested_questions_after_answer": {"enabled": True}}, - session=session, + session=sqlite_session, ) + assert not sqlite_session.in_transaction() # New row carries the features but no Soul-owned model/prompt/agent_mode. - assert new_config.app_id == "app-1" + assert new_config.app_id == APP_ID assert new_config.opening_statement == "Hi!" assert new_config.model is None assert new_config.agent_mode is None # App is repointed at the new version and the write is committed. assert app_model.app_model_config_id == new_config.id - assert app_model.updated_by == "acct-1" - assert new_config in session.added - assert session.flushed == 1 - assert session.committed == 1 + assert app_model.updated_by == ACCOUNT_ID + sqlite_session.expunge_all() + persisted_config = sqlite_session.get(AppModelConfig, new_config.id) + persisted_app = sqlite_session.get(App, APP_ID) + assert persisted_config is not None + assert persisted_config.opening_statement == "Hi!" + assert persisted_config.model is None + assert persisted_config.agent_mode is None + assert persisted_app is not None + assert persisted_app.app_model_config_id == new_config.id + assert persisted_app.updated_by == ACCOUNT_ID From 3b040e6bed9ccb3fa7e207d7527af765efcefa54 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 28 Jul 2026 15:37:45 +0900 Subject: [PATCH 094/531] test: use sqlite3 session in test_web_login (#38725) Co-authored-by: Byron.wang Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../controllers/web/test_web_login.py | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index 2ccb6b832fc..d05f135a712 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -6,13 +6,19 @@ from unittest.mock import ANY, MagicMock, patch import pytest from flask import Flask from jwt import InvalidTokenError +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, scoped_session, sessionmaker from werkzeug.exceptions import Unauthorized import services.errors.account +from controllers.console import wraps as console_wraps from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi from enums.deployment_edition import DeploymentEdition +from models.model import DifySetup from services.entities.auth_entities import LoginFailureReason +pytestmark = pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True) + def encode_code(code: str) -> str: return base64.b64encode(code.encode("utf-8")).decode() @@ -33,17 +39,27 @@ def app(): @pytest.fixture(autouse=True) -def _patch_wraps(): +def _patch_wraps( + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + sqlite_session: Session, +): wraps_features = SimpleNamespace(enable_email_password_login=True) console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True) + sqlite_session.add(DifySetup(version="test")) + sqlite_session.commit() + console_wraps._is_setup_completed.reset_success() + session_registry = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + monkeypatch.setattr(console_wraps.db, "session", session_registry) with ( - patch("controllers.console.wraps.db") as mock_db, patch("controllers.console.wraps.dify_config", console_dify), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), patch("controllers.web.login.dify_config", web_dify), ): yield + session_registry.remove() + console_wraps._is_setup_completed.reset_success() class TestEmailCodeLoginSendEmailApi: From 35b539e35b947453bb91a00cbcc0a1330c0e0665 Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 28 Jul 2026 15:02:49 +0800 Subject: [PATCH 095/531] fix: improve unconfigured agent guidance (#39676) --- .../__tests__/chat-mode-routing.spec.tsx | 65 +++++++++++++++- .../preview/__tests__/chat.spec.tsx | 75 ++++++++++++++++++- .../components/preview/build-chat.tsx | 10 ++- .../components/preview/chat-runtime.tsx | 2 +- .../components/preview/chat-session.tsx | 15 +++- .../components/preview/preview-chat.tsx | 9 +-- .../preview/unconfigured-notice.tsx | 23 ++++++ web/i18n/ar-TN/agent-v-2.json | 2 +- web/i18n/de-DE/agent-v-2.json | 2 +- web/i18n/en-US/agent-v-2.json | 2 +- web/i18n/es-ES/agent-v-2.json | 2 +- web/i18n/fa-IR/agent-v-2.json | 2 +- web/i18n/fr-FR/agent-v-2.json | 2 +- web/i18n/hi-IN/agent-v-2.json | 2 +- web/i18n/id-ID/agent-v-2.json | 2 +- web/i18n/it-IT/agent-v-2.json | 2 +- web/i18n/ja-JP/agent-v-2.json | 2 +- web/i18n/ko-KR/agent-v-2.json | 2 +- web/i18n/nl-NL/agent-v-2.json | 2 +- web/i18n/pl-PL/agent-v-2.json | 2 +- web/i18n/pt-BR/agent-v-2.json | 2 +- web/i18n/ro-RO/agent-v-2.json | 2 +- web/i18n/ru-RU/agent-v-2.json | 2 +- web/i18n/sl-SI/agent-v-2.json | 2 +- web/i18n/th-TH/agent-v-2.json | 2 +- web/i18n/tr-TR/agent-v-2.json | 2 +- web/i18n/uk-UA/agent-v-2.json | 2 +- web/i18n/vi-VN/agent-v-2.json | 2 +- web/i18n/zh-Hans/agent-v-2.json | 2 +- web/i18n/zh-Hant/agent-v-2.json | 2 +- 30 files changed, 206 insertions(+), 39 deletions(-) create mode 100644 web/features/agent-v2/agent-detail/configure/components/preview/unconfigured-notice.tsx diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx index 16347d2e8a2..4eb3df4fcc4 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat-mode-routing.spec.tsx @@ -9,6 +9,10 @@ import { sendPreviewChatMessage } from '../preview-chat-request' const runtimePropsMock = vi.hoisted(() => vi.fn()) +vi.mock('../../community-edition-tip', () => ({ + CommunityEditionTip: () => null, +})) + vi.mock('../chat-runtime', () => ({ AgentChatRuntime: ( props: Pick & { sendMessage: AgentChatMessageSender }, @@ -51,18 +55,73 @@ describe('Agent chat mode request routing', () => { expect(runtimePropsMock.mock.calls.at(-1)?.[0]).not.toHaveProperty('draftType') }) - it('should show only the agent name in the Preview empty-state title', () => { + it('should show the unconfigured notice below the Preview description', () => { render() const renderEmptyState = runtimePropsMock.mock.calls.at(-1)?.[0].renderEmptyState - render( + const emptyStateView = render( renderEmptyState({ agentName: 'Research Agent', - hasInstructions: true, + showUnconfiguredNotice: true, }), ) + const description = screen.getByText('agentV2.agentDetail.configure.preview.empty.description') + const unconfiguredNotice = screen.getByText( + 'agentV2.agentDetail.configure.preview.unconfiguredNotice', + ) + expect(screen.getByText('Research Agent')).toBeInTheDocument() expect(screen.queryByText('Preview Research Agent')).not.toBeInTheDocument() + expect( + description.compareDocumentPosition(unconfiguredNotice) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + emptyStateView.rerender( + renderEmptyState({ + agentName: 'Research Agent', + showUnconfiguredNotice: false, + }), + ) + + expect( + screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'), + ).not.toBeVisible() + expect( + screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice').closest('p'), + ).toHaveAttribute('aria-hidden', 'true') + }) + + it('should show the unconfigured notice below the Build description', () => { + render() + + const renderEmptyState = runtimePropsMock.mock.calls.at(-1)?.[0].renderEmptyState + const emptyStateView = render( + renderEmptyState({ + showUnconfiguredNotice: true, + }), + ) + + const description = screen.getByText('agentV2.agentDetail.configure.build.empty.description') + const unconfiguredNotice = screen.getByText( + 'agentV2.agentDetail.configure.preview.unconfiguredNotice', + ) + + expect( + description.compareDocumentPosition(unconfiguredNotice) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + emptyStateView.rerender( + renderEmptyState({ + showUnconfiguredNotice: false, + }), + ) + + expect( + screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'), + ).not.toBeVisible() + expect( + screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice').closest('p'), + ).toHaveAttribute('aria-hidden', 'true') }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx index dfb547a04c5..5d710dbcaae 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/chat.spec.tsx @@ -1,12 +1,15 @@ import type { ComponentProps, ReactNode } from 'react' import type { AgentPreviewChatController } from '../chat-conversation' +import type { AgentChatRuntimeEmptyStateProps } from '../chat-runtime' import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types' +import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' import { createRef, useState } from 'react' import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' import { consoleQuery } from '@/service/client' @@ -244,7 +247,16 @@ vi.mock('@/service/client', async () => { } }) -function renderPreviewChat(props?: Partial>) { +function renderUnconfiguredEmptyState({ showUnconfiguredNotice }: AgentChatRuntimeEmptyStateProps) { + return showUnconfiguredNotice ? ( + agentV2.agentDetail.configure.preview.unconfiguredNotice + ) : null +} + +function renderPreviewChat( + props?: Partial>, + draftOverrides?: Partial, +) { const store = createStore() seedRegisteredConsoleStateFixture(store) const queryClient = new QueryClient({ @@ -259,6 +271,12 @@ function renderPreviewChat(props?: Partial { expect(screen.queryByRole('button', { name: 'sandbox notice info' })).not.toBeInTheDocument() }) + it.each([ + ['Preview', undefined], + ['Build', 'debug_build' as const], + ])('should show the unconfigured warning in %s mode', (_mode, draftType) => { + renderPreviewChat( + { + draftType, + renderEmptyState: renderUnconfiguredEmptyState, + }, + { + prompt: '', + }, + ) + + expect( + screen.getByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'), + ).toBeInTheDocument() + }) + + it.each([ + ['prompt', { prompt: 'You are helpful.' }], + ['build note (config_note)', { configNote: 'Use the latest build context.' }], + ['skill', { skills: [{ id: 'skill-1', name: 'Research' }] }], + ['knowledge base', { knowledgeRetrievals: [{ id: 'retrieval-1', name: 'Docs' }] }], + ['file', { files: [{ id: 'brief.md', icon: 'markdown' as const, name: 'brief.md' }] }], + ['tool', { tools: [{ id: 'cli-1', kind: 'cli' as const, name: 'CLI' }] }], + [ + 'environment variable', + { + envVariables: [ + { + id: 'env-1', + key: 'API_KEY', + scope: 'secret' as const, + value: 'secret', + }, + ], + }, + ], + ])('should hide the unconfigured warning when the agent has a %s', (_config, draft) => { + renderPreviewChat( + { + renderEmptyState: renderUnconfiguredEmptyState, + }, + { + prompt: '', + ...draft, + }, + ) + + expect( + screen.queryByText('agentV2.agentDetail.configure.preview.unconfiguredNotice'), + ).not.toBeInTheDocument() + }) + it('should send build chat inputs from the prepared build draft snapshot', async () => { const saveDraftBeforeRun = vi.fn().mockResolvedValue({ app_variables: [ diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx index 7643b8d0ab1..b52a84843f3 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx @@ -1,10 +1,11 @@ 'use client' -import type { AgentChatRuntimeProps } from './chat-runtime' +import type { AgentChatRuntimeEmptyStateProps, AgentChatRuntimeProps } from './chat-runtime' import { useTranslation } from 'react-i18next' import { CommunityEditionTip } from '../community-edition-tip' import { sendBuildChatMessage } from './build-chat-request' import { AgentChatRuntime } from './chat-runtime' +import { AgentUnconfiguredNotice } from './unconfigured-notice' const buildIconGridCellOpacities = [ '0 0 0.093 0.166 0 0 0.155 0', @@ -27,7 +28,9 @@ type AgentBuildChatProps = Omit< 'draftType' | 'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel' | 'sendMessage' > -function AgentBuildChatEmptyState() { +function AgentBuildChatEmptyState({ + showUnconfiguredNotice, +}: Pick) { const { t } = useTranslation('agentV2') const communityEditionBuildModeTip = t( ($) => $['agentDetail.configure.build.empty.communityEditionTip'], @@ -63,6 +66,7 @@ function AgentBuildChatEmptyState() {

{t(($) => $['agentDetail.configure.build.empty.description'])}

+ ) } @@ -78,7 +82,7 @@ export function AgentBuildChat(props: AgentBuildChatProps) { inputAutoFocus={false} sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])} sendMessage={sendBuildChatMessage} - renderEmptyState={() => } + renderEmptyState={(emptyStateProps) => } /> ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx index 72468b5d18a..0da792b4c20 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat-runtime.tsx @@ -16,7 +16,7 @@ export type AgentChatRuntimeEmptyStateProps = { agentIconBackground?: string | null agentIconType?: AgentIconType | null agentName?: string - hasInstructions: boolean + showUnconfiguredNotice: boolean } export type AgentChatRuntimeProps = { diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx index 0696f38597a..95923b7522a 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/chat-session.tsx @@ -17,6 +17,7 @@ import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'rea import { useTranslation } from 'react-i18next' import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area' import { deploymentEditionAtom } from '@/context/system-features-state' +import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model' import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt' import { buildChatConfig, getAgentSoulInputs, getAgentSoulInputsForm } from './chat-config' @@ -82,6 +83,7 @@ export function AgentPreviewChatSession({ const { t } = useTranslation('agentV2') const prompt = useAtomValue(agentComposerPromptAtom) const currentModel = useAtomValue(agentComposerModelAtom) + const composerDraft = useAtomValue(agentComposerDraftAtom) const config = useMemo( () => buildChatConfig({ @@ -132,12 +134,21 @@ export function AgentPreviewChatSession({ [handleInputSend], ) const { isEmptyChat, isResponding, isSendPending } = runtimeState - const hasInstructions = !!config.pre_prompt.trim() + const hasAgentConfiguration = !!( + composerDraft.prompt.trim() || + composerDraft.skills.length || + composerDraft.files.length || + composerDraft.tools.length || + composerDraft.knowledgeRetrievals.length || + composerDraft.envVariables.length + ) + const hasBuildNote = !!composerDraft.configNote.trim() const deploymentEdition = useAtomValue(deploymentEditionAtom) const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding) const sandboxNotice = t(($) => $['agentDetail.configure.preview.sandboxNotice']) const sandboxNoticeTooltip = t(($) => $['agentDetail.configure.preview.sandboxNoticeTooltip']) const showSandboxNotice = isEmptyChat && !isSendPending && !isResponding + const showUnconfiguredNotice = showSandboxNotice && !hasAgentConfiguration && !hasBuildNote const speechToTextTarget: SpeechToTextTarget = { type: 'agent', agentId, @@ -219,7 +230,7 @@ export function AgentPreviewChatSession({ agentIconBackground, agentIconType, agentName, - hasInstructions, + showUnconfiguredNotice, })}
{chatInputNode} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx index 040d228ee76..abdc7e86f16 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/preview-chat.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { AgentChatRuntime } from './chat-runtime' import { sendPreviewChatMessage } from './preview-chat-request' +import { AgentUnconfiguredNotice } from './unconfigured-notice' type AgentPreviewChatProps = Omit< AgentChatRuntimeProps, @@ -16,7 +17,7 @@ function AgentPreviewChatEmptyState({ agentIconBackground, agentIconType, agentName, - hasInstructions, + showUnconfiguredNotice, }: AgentChatRuntimeEmptyStateProps) { const { t } = useTranslation('agentV2') const imageUrl = agentIconType === 'image' || agentIconType === 'link' ? agentIcon : undefined @@ -39,11 +40,7 @@ function AgentPreviewChatEmptyState({

{t(($) => $['agentDetail.configure.preview.empty.description'])}

- {!hasInstructions && ( -

- {t(($) => $['agentDetail.configure.preview.empty.noInstructionsDescription'])} -

- )} + ) } diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/unconfigured-notice.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/unconfigured-notice.tsx new file mode 100644 index 00000000000..a07d0c20c92 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/preview/unconfigured-notice.tsx @@ -0,0 +1,23 @@ +'use client' + +import { useTranslation } from 'react-i18next' + +export function AgentUnconfiguredNotice({ visible }: { visible: boolean }) { + const { t } = useTranslation('agentV2') + + return ( +

+ + + {t(($) => $['agentDetail.configure.preview.unconfiguredNotice'])} + +

+ ) +} diff --git a/web/i18n/ar-TN/agent-v-2.json b/web/i18n/ar-TN/agent-v-2.json index 6c99dbe1658..66481c9dbc7 100644 --- a/web/i18n/ar-TN/agent-v-2.json +++ b/web/i18n/ar-TN/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "ميزات الدردشة", "agentDetail.configure.preview.empty.defaultAgentName": "وكيل", "agentDetail.configure.preview.empty.description": "شغّل الوكيل كمحادثة مكتملة، تمامًا كما سيختبرها المستخدمون بعد النشر.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "لا توجد تعليمات بعد، لذلك تأتي الردود من النموذج البسيط.", "agentDetail.configure.preview.empty.title": "معاينة {{name}}", "agentDetail.configure.preview.endUserAuth": "مصادقة المستخدم النهائي", "agentDetail.configure.preview.inputPlaceholder": "إرسال رسالة إلى {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "يعمل Agent داخل صندوق رمل Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "بالنسبة إلى إصدار Dify Community Edition، يعمل كل Agent من Agents الخاصة بك في بيئة صندوق رمل Linux 7.0.0-10060-aws داخل Docker لديك. تعديلاتك على البيئة عبر Build Chats مستمرة.", "agentDetail.configure.preview.title": "معاينة", + "agentDetail.configure.preview.unconfiguredNotice": "لم يتم إعداد هذا الوكيل بعد، لذا تأتي الردود مباشرةً من النموذج.", "agentDetail.configure.prompt.copied": "تم نسخ المطالبة", "agentDetail.configure.prompt.copy": "نسخ المطالبة", "agentDetail.configure.prompt.copyFailed": "فشل نسخ المطالبة.", diff --git a/web/i18n/de-DE/agent-v-2.json b/web/i18n/de-DE/agent-v-2.json index 667e73b2136..5c6f7b2c364 100644 --- a/web/i18n/de-DE/agent-v-2.json +++ b/web/i18n/de-DE/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Chat-Funktionen", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "Führen Sie den Agenten als fertigen Chat aus, genau wie Benutzer ihn nach der Veröffentlichung erleben.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Noch keine Anweisungen, daher kommen die Antworten vom reinen Modell.", "agentDetail.configure.preview.empty.title": "{{name}} in der Vorschau anzeigen", "agentDetail.configure.preview.endUserAuth": "Endbenutzer-Authentifizierung", "agentDetail.configure.preview.inputPlaceholder": "Nachricht an {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Der Agent läuft in einer Linux-Sandbox.", "agentDetail.configure.preview.sandboxNoticeTooltip": "In der Dify Community Edition läuft jeder deiner Agents in einer Linux 7.0.0-10060-aws-Sandbox-Umgebung innerhalb deines Docker. Änderungen an der Umgebung über Build Chats bleiben erhalten.", "agentDetail.configure.preview.title": "Vorschau", + "agentDetail.configure.preview.unconfiguredNotice": "Dieser Agent ist noch nicht eingerichtet, daher kommen die Antworten direkt vom Modell.", "agentDetail.configure.prompt.copied": "Prompt kopiert", "agentDetail.configure.prompt.copy": "Prompt kopieren", "agentDetail.configure.prompt.copyFailed": "Prompt konnte nicht kopiert werden.", diff --git a/web/i18n/en-US/agent-v-2.json b/web/i18n/en-US/agent-v-2.json index 38b041aa1eb..ef80bffffac 100644 --- a/web/i18n/en-US/agent-v-2.json +++ b/web/i18n/en-US/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Chat Features", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "Run the agent as a finished chat, exactly how people will experience it once published.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "No instructions yet, so replies come from the plain model.", "agentDetail.configure.preview.empty.title": "Preview {{name}}", "agentDetail.configure.preview.endUserAuth": "End-user authentication", "agentDetail.configure.preview.inputPlaceholder": "Message {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent runs in a Linux sandbox.", "agentDetail.configure.preview.sandboxNoticeTooltip": "For Dify Community Edition, each of your agents runs in a Linux 7.0.0-10060-aws sandbox environment within your docker. Your edits to the environment via Build Chats are persistent.", "agentDetail.configure.preview.title": "Preview", + "agentDetail.configure.preview.unconfiguredNotice": "This agent isn't set up yet, so replies come straight from the model.", "agentDetail.configure.prompt.copied": "Prompt copied", "agentDetail.configure.prompt.copy": "Copy prompt", "agentDetail.configure.prompt.copyFailed": "Failed to copy prompt.", diff --git a/web/i18n/es-ES/agent-v-2.json b/web/i18n/es-ES/agent-v-2.json index 575d913eb41..f0e00b31e79 100644 --- a/web/i18n/es-ES/agent-v-2.json +++ b/web/i18n/es-ES/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Funciones de chat", "agentDetail.configure.preview.empty.defaultAgentName": "Agente", "agentDetail.configure.preview.empty.description": "Ejecuta el agente como un chat terminado, exactamente como lo verán las personas cuando se publique.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Aún no hay instrucciones, así que las respuestas vendrán del modelo sin configurar.", "agentDetail.configure.preview.empty.title": "Vista previa de {{name}}", "agentDetail.configure.preview.endUserAuth": "Autenticación de usuario final", "agentDetail.configure.preview.inputPlaceholder": "Enviar mensaje a {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "El agente se ejecuta en un sandbox de Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "En Dify Community Edition, cada uno de tus agentes se ejecuta en un entorno sandbox Linux 7.0.0-10060-aws dentro de tu Docker. Tus cambios en el entorno mediante Build Chats son persistentes.", "agentDetail.configure.preview.title": "Vista previa", + "agentDetail.configure.preview.unconfiguredNotice": "Este agente aún no está configurado, por lo que las respuestas provienen directamente del modelo.", "agentDetail.configure.prompt.copied": "Prompt copiado", "agentDetail.configure.prompt.copy": "Copiar prompt", "agentDetail.configure.prompt.copyFailed": "Error al copiar el prompt.", diff --git a/web/i18n/fa-IR/agent-v-2.json b/web/i18n/fa-IR/agent-v-2.json index f5010625e3f..33cec965a5e 100644 --- a/web/i18n/fa-IR/agent-v-2.json +++ b/web/i18n/fa-IR/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "ویژگی‌های چت", "agentDetail.configure.preview.empty.defaultAgentName": "عامل", "agentDetail.configure.preview.empty.description": "عامل را مثل یک چت نهایی اجرا کنید، دقیقاً همان‌طور که کاربران پس از انتشار تجربه می‌کنند.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "هنوز دستورالعملی وجود ندارد، بنابراین پاسخ‌ها از مدل ساده می‌آیند.", "agentDetail.configure.preview.empty.title": "پیش‌نمایش {{name}}", "agentDetail.configure.preview.endUserAuth": "احراز هویت کاربر نهایی", "agentDetail.configure.preview.inputPlaceholder": "ارسال پیام به {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent در یک سندباکس لینوکس اجرا می‌شود.", "agentDetail.configure.preview.sandboxNoticeTooltip": "در Dify Community Edition، هر یک از Agentهای شما در یک محیط سندباکس Linux 7.0.0-10060-aws داخل Docker شما اجرا می‌شود. ویرایش‌های شما روی محیط از طریق Build Chats پایدار می‌ماند.", "agentDetail.configure.preview.title": "پیش‌نمایش", + "agentDetail.configure.preview.unconfiguredNotice": "این عامل هنوز راه‌اندازی نشده است، بنابراین پاسخ‌ها مستقیماً از مدل می‌آیند.", "agentDetail.configure.prompt.copied": "پرامپت کپی شد", "agentDetail.configure.prompt.copy": "کپی پرامپت", "agentDetail.configure.prompt.copyFailed": "کپی پرامپت ناموفق بود.", diff --git a/web/i18n/fr-FR/agent-v-2.json b/web/i18n/fr-FR/agent-v-2.json index aba60914426..ba5cba90820 100644 --- a/web/i18n/fr-FR/agent-v-2.json +++ b/web/i18n/fr-FR/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Fonctionnalités de chat", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "Exécutez l’agent comme un chat finalisé, exactement comme les utilisateurs le verront une fois publié.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Pas encore d’instructions, donc les réponses proviennent du modèle brut.", "agentDetail.configure.preview.empty.title": "Aperçu de {{name}}", "agentDetail.configure.preview.endUserAuth": "Authentification de l’utilisateur final", "agentDetail.configure.preview.inputPlaceholder": "Envoyer un message à {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "L’agent s’exécute dans un bac à sable Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "Dans Dify Community Edition, chacun de vos agents s’exécute dans un environnement bac à sable Linux 7.0.0-10060-aws au sein de votre Docker. Les modifications apportées à l’environnement via Build Chats sont persistantes.", "agentDetail.configure.preview.title": "Aperçu", + "agentDetail.configure.preview.unconfiguredNotice": "Cet agent n’est pas encore configuré, les réponses proviennent donc directement du modèle.", "agentDetail.configure.prompt.copied": "Prompt copié", "agentDetail.configure.prompt.copy": "Copier le prompt", "agentDetail.configure.prompt.copyFailed": "Échec de la copie du prompt.", diff --git a/web/i18n/hi-IN/agent-v-2.json b/web/i18n/hi-IN/agent-v-2.json index 4bdfef1d32a..a8a86bb18d0 100644 --- a/web/i18n/hi-IN/agent-v-2.json +++ b/web/i18n/hi-IN/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "चैट सुविधाएँ", "agentDetail.configure.preview.empty.defaultAgentName": "एजेंट", "agentDetail.configure.preview.empty.description": "एजेंट को तैयार चैट की तरह चलाएं, ठीक वैसे जैसे लोग प्रकाशित होने के बाद अनुभव करेंगे।", - "agentDetail.configure.preview.empty.noInstructionsDescription": "अभी तक कोई निर्देश नहीं, इसलिए उत्तर सामान्य मॉडल से आते हैं।", "agentDetail.configure.preview.empty.title": "{{name}} का पूर्वावलोकन", "agentDetail.configure.preview.endUserAuth": "अंतिम-उपयोगकर्ता प्रमाणीकरण", "agentDetail.configure.preview.inputPlaceholder": "{{name}} को संदेश भेजें", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent Linux सैंडबॉक्स में चलता है.", "agentDetail.configure.preview.sandboxNoticeTooltip": "Dify Community Edition में, आपके हर Agent आपके Docker के अंदर Linux 7.0.0-10060-aws सैंडबॉक्स वातावरण में चलता है. Build Chats के ज़रिए वातावरण में किए गए आपके बदलाव स्थायी रहते हैं.", "agentDetail.configure.preview.title": "पूर्वावलोकन", + "agentDetail.configure.preview.unconfiguredNotice": "यह एजेंट अभी सेट अप नहीं किया गया है, इसलिए जवाब सीधे मॉडल से आते हैं।", "agentDetail.configure.prompt.copied": "प्रॉम्प्ट कॉपी हो गया", "agentDetail.configure.prompt.copy": "प्रॉम्प्ट कॉपी करें", "agentDetail.configure.prompt.copyFailed": "प्रॉम्प्ट कॉपी करने में विफल।", diff --git a/web/i18n/id-ID/agent-v-2.json b/web/i18n/id-ID/agent-v-2.json index 44a23a33b29..85e5e0c4ded 100644 --- a/web/i18n/id-ID/agent-v-2.json +++ b/web/i18n/id-ID/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Fitur Chat", "agentDetail.configure.preview.empty.defaultAgentName": "Agen", "agentDetail.configure.preview.empty.description": "Jalankan agen sebagai chat final, persis seperti yang akan dialami orang setelah diterbitkan.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Belum ada instruksi, jadi balasan datang dari model polos.", "agentDetail.configure.preview.empty.title": "Pratinjau {{name}}", "agentDetail.configure.preview.endUserAuth": "Autentikasi pengguna akhir", "agentDetail.configure.preview.inputPlaceholder": "Kirim pesan ke {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent berjalan di sandbox Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "Di Dify Community Edition, setiap Agent Anda berjalan di lingkungan sandbox Linux 7.0.0-10060-aws di dalam Docker Anda. Perubahan yang Anda buat pada lingkungan melalui Build Chats bersifat persisten.", "agentDetail.configure.preview.title": "Pratinjau", + "agentDetail.configure.preview.unconfiguredNotice": "Agen ini belum disiapkan, jadi balasan langsung berasal dari model.", "agentDetail.configure.prompt.copied": "Prompt disalin", "agentDetail.configure.prompt.copy": "Salin prompt", "agentDetail.configure.prompt.copyFailed": "Gagal menyalin prompt.", diff --git a/web/i18n/it-IT/agent-v-2.json b/web/i18n/it-IT/agent-v-2.json index ec5b3238c8b..fd7a6520645 100644 --- a/web/i18n/it-IT/agent-v-2.json +++ b/web/i18n/it-IT/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Funzionalità chat", "agentDetail.configure.preview.empty.defaultAgentName": "Agente", "agentDetail.configure.preview.empty.description": "Esegui l’agente come una chat completa, esattamente come verrà vissuta dopo la pubblicazione.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Nessuna istruzione ancora, quindi le risposte arrivano dal modello puro.", "agentDetail.configure.preview.empty.title": "Anteprima di {{name}}", "agentDetail.configure.preview.endUserAuth": "Autenticazione utente finale", "agentDetail.configure.preview.inputPlaceholder": "Invia un messaggio a {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "L'agent viene eseguito in una sandbox Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "In Dify Community Edition, ciascuno dei tuoi agent viene eseguito in un ambiente sandbox Linux 7.0.0-10060-aws all’interno del tuo Docker. Le modifiche all’ambiente tramite Build Chats sono persistenti.", "agentDetail.configure.preview.title": "Anteprima", + "agentDetail.configure.preview.unconfiguredNotice": "Questo agente non è ancora configurato, quindi le risposte provengono direttamente dal modello.", "agentDetail.configure.prompt.copied": "Prompt copiato", "agentDetail.configure.prompt.copy": "Copia prompt", "agentDetail.configure.prompt.copyFailed": "Impossibile copiare il prompt.", diff --git a/web/i18n/ja-JP/agent-v-2.json b/web/i18n/ja-JP/agent-v-2.json index 2d6ade40c66..cb185365ad1 100644 --- a/web/i18n/ja-JP/agent-v-2.json +++ b/web/i18n/ja-JP/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "チャット機能", "agentDetail.configure.preview.empty.defaultAgentName": "エージェント", "agentDetail.configure.preview.empty.description": "公開後にユーザーが体験する完成版のチャットとしてエージェントを実行します。", - "agentDetail.configure.preview.empty.noInstructionsDescription": "指示が設定されていないため、応答はプレーンなモデルから返ります。", "agentDetail.configure.preview.empty.title": "{{name}} をプレビュー", "agentDetail.configure.preview.endUserAuth": "エンドユーザー認証", "agentDetail.configure.preview.inputPlaceholder": "{{name}} にメッセージを送信", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent は Linux サンドボックスで実行されます。", "agentDetail.configure.preview.sandboxNoticeTooltip": "Dify Community Edition では、各 Agent は Docker 内の Linux 7.0.0-10060-aws サンドボックス環境で実行されます。Build Chats で行った環境への編集は永続化されます。", "agentDetail.configure.preview.title": "プレビュー", + "agentDetail.configure.preview.unconfiguredNotice": "このエージェントはまだ設定されていないため、応答はモデルから直接返されます。", "agentDetail.configure.prompt.copied": "プロンプトをコピーしました", "agentDetail.configure.prompt.copy": "プロンプトをコピー", "agentDetail.configure.prompt.copyFailed": "プロンプトのコピーに失敗しました。", diff --git a/web/i18n/ko-KR/agent-v-2.json b/web/i18n/ko-KR/agent-v-2.json index 822a7cebf1e..ef971861801 100644 --- a/web/i18n/ko-KR/agent-v-2.json +++ b/web/i18n/ko-KR/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "채팅 기능", "agentDetail.configure.preview.empty.defaultAgentName": "에이전트", "agentDetail.configure.preview.empty.description": "게시 후 사용자가 경험할 완성된 채팅처럼 에이전트를 실행합니다.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "아직 지시 사항이 없어 답변은 기본 모델에서 제공됩니다.", "agentDetail.configure.preview.empty.title": "{{name}} 미리보기", "agentDetail.configure.preview.endUserAuth": "최종 사용자 인증", "agentDetail.configure.preview.inputPlaceholder": "{{name}}에게 메시지 보내기", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent는 Linux 샌드박스에서 실행됩니다.", "agentDetail.configure.preview.sandboxNoticeTooltip": "Dify Community Edition에서는 각 Agent가 Docker 내부의 Linux 7.0.0-10060-aws 샌드박스 환경에서 실행됩니다. Build Chats를 통해 환경에 적용한 변경 사항은 유지됩니다.", "agentDetail.configure.preview.title": "미리보기", + "agentDetail.configure.preview.unconfiguredNotice": "이 에이전트는 아직 설정되지 않아 응답이 모델에서 바로 제공됩니다.", "agentDetail.configure.prompt.copied": "프롬프트를 복사했습니다", "agentDetail.configure.prompt.copy": "프롬프트 복사", "agentDetail.configure.prompt.copyFailed": "프롬프트 복사에 실패했습니다.", diff --git a/web/i18n/nl-NL/agent-v-2.json b/web/i18n/nl-NL/agent-v-2.json index e9a481dd27f..33d0ab55982 100644 --- a/web/i18n/nl-NL/agent-v-2.json +++ b/web/i18n/nl-NL/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Chatfuncties", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "Voer de agent uit als een afgeronde chat, precies zoals mensen die na publicatie ervaren.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Nog geen instructies, dus antwoorden komen van het kale model.", "agentDetail.configure.preview.empty.title": "Voorbeeld van {{name}}", "agentDetail.configure.preview.endUserAuth": "Authenticatie eindgebruiker", "agentDetail.configure.preview.inputPlaceholder": "Bericht sturen naar {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "De agent wordt uitgevoerd in een Linux-sandbox.", "agentDetail.configure.preview.sandboxNoticeTooltip": "In Dify Community Edition draait elk van je agents in een Linux 7.0.0-10060-aws-sandboxomgeving binnen je Docker. Je wijzigingen aan de omgeving via Build Chats blijven behouden.", "agentDetail.configure.preview.title": "Voorbeeld", + "agentDetail.configure.preview.unconfiguredNotice": "Deze agent is nog niet ingesteld, dus antwoorden komen rechtstreeks van het model.", "agentDetail.configure.prompt.copied": "Prompt gekopieerd", "agentDetail.configure.prompt.copy": "Prompt kopiëren", "agentDetail.configure.prompt.copyFailed": "Prompt kopiëren mislukt.", diff --git a/web/i18n/pl-PL/agent-v-2.json b/web/i18n/pl-PL/agent-v-2.json index 08d38a78fac..eb173d10d9a 100644 --- a/web/i18n/pl-PL/agent-v-2.json +++ b/web/i18n/pl-PL/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Funkcje czatu", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "Uruchom agenta jako gotowy czat, dokładnie tak, jak zobaczą go użytkownicy po publikacji.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Brak instrukcji, więc odpowiedzi pochodzą z podstawowego modelu.", "agentDetail.configure.preview.empty.title": "Podgląd {{name}}", "agentDetail.configure.preview.endUserAuth": "Uwierzytelnianie użytkownika końcowego", "agentDetail.configure.preview.inputPlaceholder": "Wyślij wiadomość do {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent działa w piaskownicy Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "W Dify Community Edition każdy z Twoich agentów działa w środowisku piaskownicy Linux 7.0.0-10060-aws w Twoim Dockerze. Zmiany środowiska wprowadzone przez Build Chats są trwałe.", "agentDetail.configure.preview.title": "Podgląd", + "agentDetail.configure.preview.unconfiguredNotice": "Ten agent nie jest jeszcze skonfigurowany, więc odpowiedzi pochodzą bezpośrednio z modelu.", "agentDetail.configure.prompt.copied": "Prompt skopiowany", "agentDetail.configure.prompt.copy": "Kopiuj prompt", "agentDetail.configure.prompt.copyFailed": "Nie udało się skopiować promptu.", diff --git a/web/i18n/pt-BR/agent-v-2.json b/web/i18n/pt-BR/agent-v-2.json index 7be178ce8a4..f47a97baec3 100644 --- a/web/i18n/pt-BR/agent-v-2.json +++ b/web/i18n/pt-BR/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Recursos de chat", "agentDetail.configure.preview.empty.defaultAgentName": "Agente", "agentDetail.configure.preview.empty.description": "Execute o agente como um chat finalizado, exatamente como as pessoas verão após a publicação.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Ainda não há instruções, então as respostas virão do modelo puro.", "agentDetail.configure.preview.empty.title": "Pré-visualizar {{name}}", "agentDetail.configure.preview.endUserAuth": "Autenticação do usuário final", "agentDetail.configure.preview.inputPlaceholder": "Enviar mensagem para {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "O agente é executado em uma sandbox Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "No Dify Community Edition, cada um dos seus agents é executado em um ambiente sandbox Linux 7.0.0-10060-aws dentro do seu Docker. Suas edições no ambiente via Build Chats são persistentes.", "agentDetail.configure.preview.title": "Pré-visualização", + "agentDetail.configure.preview.unconfiguredNotice": "Este agente ainda não foi configurado, então as respostas vêm diretamente do modelo.", "agentDetail.configure.prompt.copied": "Prompt copiado", "agentDetail.configure.prompt.copy": "Copiar prompt", "agentDetail.configure.prompt.copyFailed": "Falha ao copiar o prompt.", diff --git a/web/i18n/ro-RO/agent-v-2.json b/web/i18n/ro-RO/agent-v-2.json index 7878a64e265..3050c2e47e7 100644 --- a/web/i18n/ro-RO/agent-v-2.json +++ b/web/i18n/ro-RO/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Funcții de chat", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "Rulează agentul ca un chat finalizat, exact cum îl vor experimenta utilizatorii după publicare.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Nu există încă instrucțiuni, așa că răspunsurile vin de la modelul de bază.", "agentDetail.configure.preview.empty.title": "Previzualizează {{name}}", "agentDetail.configure.preview.endUserAuth": "Autentificare utilizator final", "agentDetail.configure.preview.inputPlaceholder": "Trimite mesaj către {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agentul rulează într-un sandbox Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "În Dify Community Edition, fiecare dintre agenții tăi rulează într-un mediu sandbox Linux 7.0.0-10060-aws în Dockerul tău. Modificările aduse mediului prin Build Chats sunt persistente.", "agentDetail.configure.preview.title": "Previzualizare", + "agentDetail.configure.preview.unconfiguredNotice": "Acest agent nu este încă configurat, așa că răspunsurile vin direct de la model.", "agentDetail.configure.prompt.copied": "Prompt copiat", "agentDetail.configure.prompt.copy": "Copiază prompt-ul", "agentDetail.configure.prompt.copyFailed": "Copierea prompt-ului a eșuat.", diff --git a/web/i18n/ru-RU/agent-v-2.json b/web/i18n/ru-RU/agent-v-2.json index 75189915bd4..7b388932a20 100644 --- a/web/i18n/ru-RU/agent-v-2.json +++ b/web/i18n/ru-RU/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Функции чата", "agentDetail.configure.preview.empty.defaultAgentName": "Агент", "agentDetail.configure.preview.empty.description": "Запустите агента как готовый чат, именно так, как его увидят пользователи после публикации.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Инструкций пока нет, поэтому ответы приходят от базовой модели.", "agentDetail.configure.preview.empty.title": "Предпросмотр {{name}}", "agentDetail.configure.preview.endUserAuth": "Аутентификация конечного пользователя", "agentDetail.configure.preview.inputPlaceholder": "Написать {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent работает в песочнице Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "В Dify Community Edition каждый ваш Agent работает в среде песочницы Linux 7.0.0-10060-aws внутри вашего Docker. Изменения среды через Build Chats сохраняются.", "agentDetail.configure.preview.title": "Предпросмотр", + "agentDetail.configure.preview.unconfiguredNotice": "Этот агент ещё не настроен, поэтому ответы поступают напрямую от модели.", "agentDetail.configure.prompt.copied": "Промпт скопирован", "agentDetail.configure.prompt.copy": "Копировать промпт", "agentDetail.configure.prompt.copyFailed": "Не удалось скопировать промпт.", diff --git a/web/i18n/sl-SI/agent-v-2.json b/web/i18n/sl-SI/agent-v-2.json index 64ca2c71d70..8d38d6cfa35 100644 --- a/web/i18n/sl-SI/agent-v-2.json +++ b/web/i18n/sl-SI/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Funkcije klepeta", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "Zaženite agenta kot dokončan klepet, natanko tako, kot ga bodo uporabniki doživeli po objavi.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Ni še navodil, zato odgovori prihajajo iz osnovnega modela.", "agentDetail.configure.preview.empty.title": "Predogled {{name}}", "agentDetail.configure.preview.endUserAuth": "Preverjanje pristnosti končnega uporabnika", "agentDetail.configure.preview.inputPlaceholder": "Pošlji sporočilo za {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent se izvaja v peskovniku Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "V Dify Community Edition vsak vaš Agent teče v peskovniškem okolju Linux 7.0.0-10060-aws znotraj vašega Dockerja. Spremembe okolja prek Build Chats so trajne.", "agentDetail.configure.preview.title": "Predogled", + "agentDetail.configure.preview.unconfiguredNotice": "Ta agent še ni nastavljen, zato odgovori prihajajo neposredno iz modela.", "agentDetail.configure.prompt.copied": "Poziv kopiran", "agentDetail.configure.prompt.copy": "Kopiraj poziv", "agentDetail.configure.prompt.copyFailed": "Poziva ni bilo mogoče kopirati.", diff --git a/web/i18n/th-TH/agent-v-2.json b/web/i18n/th-TH/agent-v-2.json index edba1cd3e0f..c4159521a95 100644 --- a/web/i18n/th-TH/agent-v-2.json +++ b/web/i18n/th-TH/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "ฟีเจอร์แชท", "agentDetail.configure.preview.empty.defaultAgentName": "ตัวแทน", "agentDetail.configure.preview.empty.description": "เรียกใช้เอเจนต์เป็นแชทที่เสร็จสมบูรณ์ เหมือนที่ผู้ใช้จะได้รับหลังเผยแพร่", - "agentDetail.configure.preview.empty.noInstructionsDescription": "ยังไม่มีคำสั่ง ดังนั้นการตอบกลับจะมาจากโมเดลพื้นฐาน", "agentDetail.configure.preview.empty.title": "แสดงตัวอย่าง {{name}}", "agentDetail.configure.preview.endUserAuth": "การยืนยันตัวตนของผู้ใช้ปลายทาง", "agentDetail.configure.preview.inputPlaceholder": "ส่งข้อความถึง {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent ทำงานในแซนด์บ็อกซ์ Linux", "agentDetail.configure.preview.sandboxNoticeTooltip": "สำหรับ Dify Community Edition Agent แต่ละตัวของคุณจะทำงานในสภาพแวดล้อมแซนด์บ็อกซ์ Linux 7.0.0-10060-aws ภายใน Docker ของคุณ การแก้ไขสภาพแวดล้อมผ่าน Build Chats จะคงอยู่ถาวร", "agentDetail.configure.preview.title": "แสดงตัวอย่าง", + "agentDetail.configure.preview.unconfiguredNotice": "เอเจนต์นี้ยังไม่ได้ตั้งค่า ดังนั้นการตอบกลับจึงมาจากโมเดลโดยตรง", "agentDetail.configure.prompt.copied": "คัดลอกพรอมต์แล้ว", "agentDetail.configure.prompt.copy": "คัดลอกพรอมต์", "agentDetail.configure.prompt.copyFailed": "คัดลอกพรอมต์ไม่สำเร็จ", diff --git a/web/i18n/tr-TR/agent-v-2.json b/web/i18n/tr-TR/agent-v-2.json index ad526c60d26..25554b3d966 100644 --- a/web/i18n/tr-TR/agent-v-2.json +++ b/web/i18n/tr-TR/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Sohbet Özellikleri", "agentDetail.configure.preview.empty.defaultAgentName": "Ajan", "agentDetail.configure.preview.empty.description": "Aracıyı yayınlandıktan sonra kullanıcıların deneyimleyeceği tamamlanmış sohbet olarak çalıştırın.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Henüz talimat yok, bu yüzden yanıtlar düz modelden geliyor.", "agentDetail.configure.preview.empty.title": "{{name}} önizlemesi", "agentDetail.configure.preview.endUserAuth": "Son kullanıcı kimlik doğrulaması", "agentDetail.configure.preview.inputPlaceholder": "{{name}} ile mesajlaş", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent bir Linux korumalı alanında çalışır.", "agentDetail.configure.preview.sandboxNoticeTooltip": "Dify Community Edition’da, agent’larınızın her biri Docker’ınız içinde Linux 7.0.0-10060-aws korumalı alan ortamında çalışır. Build Chats üzerinden ortamda yaptığınız düzenlemeler kalıcıdır.", "agentDetail.configure.preview.title": "Önizleme", + "agentDetail.configure.preview.unconfiguredNotice": "Bu temsilci henüz ayarlanmadı, bu nedenle yanıtlar doğrudan modelden gelir.", "agentDetail.configure.prompt.copied": "İstem kopyalandı", "agentDetail.configure.prompt.copy": "İstemi kopyala", "agentDetail.configure.prompt.copyFailed": "İstem kopyalanamadı.", diff --git a/web/i18n/uk-UA/agent-v-2.json b/web/i18n/uk-UA/agent-v-2.json index 5c054ec2815..ac884675a03 100644 --- a/web/i18n/uk-UA/agent-v-2.json +++ b/web/i18n/uk-UA/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Функції чату", "agentDetail.configure.preview.empty.defaultAgentName": "Агент", "agentDetail.configure.preview.empty.description": "Запустіть агента як готовий чат, саме так, як його побачать користувачі після публікації.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Інструкцій ще немає, тому відповіді надходять від базової моделі.", "agentDetail.configure.preview.empty.title": "Перегляд {{name}}", "agentDetail.configure.preview.endUserAuth": "Автентифікація кінцевого користувача", "agentDetail.configure.preview.inputPlaceholder": "Надіслати повідомлення {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent працює в пісочниці Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "У Dify Community Edition кожен ваш Agent працює в середовищі пісочниці Linux 7.0.0-10060-aws у вашому Docker. Зміни середовища через Build Chats зберігаються.", "agentDetail.configure.preview.title": "Перегляд", + "agentDetail.configure.preview.unconfiguredNotice": "Цей агент ще не налаштований, тому відповіді надходять безпосередньо від моделі.", "agentDetail.configure.prompt.copied": "Промпт скопійовано", "agentDetail.configure.prompt.copy": "Скопіювати промпт", "agentDetail.configure.prompt.copyFailed": "Не вдалося скопіювати промпт.", diff --git a/web/i18n/vi-VN/agent-v-2.json b/web/i18n/vi-VN/agent-v-2.json index 92080c04bb2..ab03e9ca394 100644 --- a/web/i18n/vi-VN/agent-v-2.json +++ b/web/i18n/vi-VN/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Tính năng trò chuyện", "agentDetail.configure.preview.empty.defaultAgentName": "Tác nhân", "agentDetail.configure.preview.empty.description": "Chạy tác nhân như một cuộc trò chuyện hoàn chỉnh, đúng như người dùng sẽ trải nghiệm sau khi xuất bản.", - "agentDetail.configure.preview.empty.noInstructionsDescription": "Chưa có hướng dẫn, vì vậy câu trả lời đến từ mô hình thuần.", "agentDetail.configure.preview.empty.title": "Xem trước {{name}}", "agentDetail.configure.preview.endUserAuth": "Xác thực người dùng cuối", "agentDetail.configure.preview.inputPlaceholder": "Nhắn cho {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent chạy trong sandbox Linux.", "agentDetail.configure.preview.sandboxNoticeTooltip": "Trong Dify Community Edition, mỗi Agent của bạn chạy trong môi trường sandbox Linux 7.0.0-10060-aws bên trong Docker của bạn. Các chỉnh sửa môi trường qua Build Chats sẽ được lưu giữ.", "agentDetail.configure.preview.title": "Xem trước", + "agentDetail.configure.preview.unconfiguredNotice": "Agent này chưa được thiết lập, vì vậy câu trả lời đến trực tiếp từ mô hình.", "agentDetail.configure.prompt.copied": "Đã sao chép lời nhắc", "agentDetail.configure.prompt.copy": "Sao chép lời nhắc", "agentDetail.configure.prompt.copyFailed": "Sao chép lời nhắc thất bại.", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index a4de41004fb..ea1467e802b 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Chat 功能", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "像已发布后用户体验到的那样运行 Agent。", - "agentDetail.configure.preview.empty.noInstructionsDescription": "尚未设置指令,回复将来自基础模型。", "agentDetail.configure.preview.empty.title": "预览 {{name}}", "agentDetail.configure.preview.endUserAuth": "终端用户认证", "agentDetail.configure.preview.inputPlaceholder": "向 {{name}} 发送消息", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent 运行在 Linux 沙盒中。", "agentDetail.configure.preview.sandboxNoticeTooltip": "在 Dify Community Edition 中,每个 Agent 都运行在你 Docker 内的 Linux 7.0.0-10060-aws 沙盒环境中。你通过 Build Chats 对环境所做的编辑会持久保留。", "agentDetail.configure.preview.title": "预览", + "agentDetail.configure.preview.unconfiguredNotice": "此 Agent 尚未设置,因此回复将直接来自模型。", "agentDetail.configure.prompt.copied": "提示词已复制", "agentDetail.configure.prompt.copy": "复制提示词", "agentDetail.configure.prompt.copyFailed": "提示词复制失败。", diff --git a/web/i18n/zh-Hant/agent-v-2.json b/web/i18n/zh-Hant/agent-v-2.json index dde68a244f2..8f538159065 100644 --- a/web/i18n/zh-Hant/agent-v-2.json +++ b/web/i18n/zh-Hant/agent-v-2.json @@ -154,7 +154,6 @@ "agentDetail.configure.preview.chatFeatures": "Chat 功能", "agentDetail.configure.preview.empty.defaultAgentName": "Agent", "agentDetail.configure.preview.empty.description": "像發布後使用者體驗到的那樣執行 Agent。", - "agentDetail.configure.preview.empty.noInstructionsDescription": "尚未設定指令,回覆將來自基礎模型。", "agentDetail.configure.preview.empty.title": "預覽 {{name}}", "agentDetail.configure.preview.endUserAuth": "終端使用者驗證", "agentDetail.configure.preview.inputPlaceholder": "傳訊息給 {{name}}", @@ -162,6 +161,7 @@ "agentDetail.configure.preview.sandboxNotice": "Agent 執行於 Linux 沙盒中。", "agentDetail.configure.preview.sandboxNoticeTooltip": "在 Dify Community Edition 中,每個 Agent 都執行於你 Docker 內的 Linux 7.0.0-10060-aws 沙盒環境。你透過 Build Chats 對環境所做的編輯會持久保留。", "agentDetail.configure.preview.title": "預覽", + "agentDetail.configure.preview.unconfiguredNotice": "此 Agent 尚未設定,因此回覆將直接來自模型。", "agentDetail.configure.prompt.copied": "提示詞已複製", "agentDetail.configure.prompt.copy": "複製提示詞", "agentDetail.configure.prompt.copyFailed": "提示詞複製失敗。", From 7ec6a57ddf53fe0a5b886d7da0ab4ef33369b375 Mon Sep 17 00:00:00 2001 From: Shakti Date: Tue, 28 Jul 2026 12:36:49 +0530 Subject: [PATCH 096/531] fix: populate completion_params from model schema defaults in Agent node (#39590) --- .../workflow/nodes/agent/runtime_support.py | 33 ++++++ .../nodes/agent/test_runtime_support.py | 106 +++++++++++++++++- 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/api/core/workflow/nodes/agent/runtime_support.py b/api/core/workflow/nodes/agent/runtime_support.py index a872774c98c..9a36e87e015 100644 --- a/api/core/workflow/nodes/agent/runtime_support.py +++ b/api/core/workflow/nodes/agent/runtime_support.py @@ -198,8 +198,23 @@ class AgentRuntimeSupport: if model_schema: model_schema = self._remove_unsupported_model_features_for_old_version(model_schema) value["entity"] = model_schema.model_dump(mode="json") + # The model selector value from the workflow frontend only + # carries provider/model/mode — it does NOT include + # completion_params. AgentStrategy plugins (cot_agent, + # function_calling) read completion_params to build the + # LLMModelConfig that is backwards-invoked, and some model + # providers raise KeyError('required') when + # completion_params is empty because their parameter_rules + # declare required fields with no default. Populate + # completion_params with the defaults declared in the model + # schema so the plugin daemon always receives a valid set + # of model parameters. + if "completion_params" not in value: + value["completion_params"] = self._extract_default_completion_params(model_schema) else: value["entity"] = None + if "completion_params" not in value: + value["completion_params"] = {} result[parameter_name] = value return result @@ -275,6 +290,24 @@ class AgentRuntimeSupport: model_schema.features.remove(feature) return model_schema + @staticmethod + def _extract_default_completion_params(model_schema: AIModelEntity) -> dict[str, Any]: + """Build a completion_params dict from the model schema's parameter_rules. + + The workflow Agent node's model-selector parameter only stores + provider/model/mode — it never carries completion_params. When the + value is forwarded to the plugin daemon, AgentModelConfig defaults + completion_params to ``{}``, which causes some model providers to fail + because their parameter_rules declare required fields. This helper + collects the ``default`` value of every parameter_rule that has one so + the plugin daemon receives a valid, non-empty set of model parameters. + """ + completion_params: dict[str, Any] = {} + for rule in model_schema.parameter_rules: + if rule.default is not None: + completion_params[rule.name] = rule.default + return completion_params + @staticmethod def _filter_mcp_type_tool( strategy: ResolvedAgentStrategy, diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py index c86de7f6e63..f79f4282649 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py @@ -2,7 +2,16 @@ from types import SimpleNamespace from unittest.mock import Mock, patch from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport -from graphon.model_runtime.entities.model_entities import ModelType +from graphon.model_runtime.entities.common_entities import I18nObject +from graphon.model_runtime.entities.model_entities import ( + AIModelEntity, + FetchFrom, + ModelFeature, + ModelPropertyKey, + ModelType, + ParameterRule, + ParameterType, +) def test_fetch_model_reuses_single_model_assembly(): @@ -47,3 +56,98 @@ def test_fetch_model_reuses_single_model_assembly(): model_type=ModelType.LLM, model="gpt-4o-mini", ) + + +def _make_model_schema_with_defaults() -> AIModelEntity: + """Return a minimal AIModelEntity whose parameter_rules carry defaults.""" + return AIModelEntity( + model="qwen-max", + label=I18nObject(en_US="Qwen Max"), + model_type=ModelType.LLM, + features=[ModelFeature.AGENT_THOUGHT, ModelFeature.MULTI_TOOL_CALL], + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={ + ModelPropertyKey.MODE: "chat", + ModelPropertyKey.CONTEXT_SIZE: 32768, + }, + parameter_rules=[ + ParameterRule( + name="temperature", + use_template="temperature", + label=I18nObject(en_US="Temperature"), + type=ParameterType.FLOAT, + required=False, + default=0.7, + min=0.0, + max=2.0, + precision=2, + ), + ParameterRule( + name="max_tokens", + use_template="max_tokens", + label=I18nObject(en_US="Max Tokens"), + type=ParameterType.INT, + required=False, + default=2048, + min=1, + max=32768, + ), + ParameterRule( + name="top_p", + use_template="top_p", + label=I18nObject(en_US="Top P"), + type=ParameterType.FLOAT, + required=False, + default=1.0, + ), + ], + ) + + +def test_extract_default_completion_params_collects_rule_defaults(): + """_extract_default_completion_params should gather every rule.default.""" + schema = _make_model_schema_with_defaults() + params = AgentRuntimeSupport._extract_default_completion_params(schema) + assert params == {"temperature": 0.7, "max_tokens": 2048, "top_p": 1.0} + + +def test_extract_default_completion_params_skips_rules_without_default(): + """Rules whose default is None must not appear in the result.""" + schema = AIModelEntity( + model="test-model", + label=I18nObject(en_US="Test"), + model_type=ModelType.LLM, + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={ModelPropertyKey.MODE: "chat"}, + parameter_rules=[ + ParameterRule( + name="seed", + label=I18nObject(en_US="Seed"), + type=ParameterType.INT, + required=False, + default=None, + ), + ParameterRule( + name="temperature", + label=I18nObject(en_US="Temperature"), + type=ParameterType.FLOAT, + required=False, + default=0.5, + ), + ], + ) + params = AgentRuntimeSupport._extract_default_completion_params(schema) + assert params == {"temperature": 0.5} + + +def test_extract_default_completion_params_empty_when_no_defaults(): + """An empty parameter_rules list yields an empty dict.""" + schema = AIModelEntity( + model="test-model", + label=I18nObject(en_US="Test"), + model_type=ModelType.LLM, + fetch_from=FetchFrom.PREDEFINED_MODEL, + model_properties={ModelPropertyKey.MODE: "chat"}, + parameter_rules=[], + ) + assert AgentRuntimeSupport._extract_default_completion_params(schema) == {} From d94314627f2c0cacb20620ca973f1b9b6e46b4b9 Mon Sep 17 00:00:00 2001 From: Jyong <76649700+JohnJyong@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:12:29 -0400 Subject: [PATCH 097/531] ci: wait for KnowledgeFS before deployment (#39677) --- .github/workflows/deploy-knowledge.yml | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/deploy-knowledge.yml b/.github/workflows/deploy-knowledge.yml index 26bc39d8bc0..7e2b3a086ac 100644 --- a/.github/workflows/deploy-knowledge.yml +++ b/.github/workflows/deploy-knowledge.yml @@ -1,6 +1,7 @@ name: Deploy Knowledge permissions: + actions: read contents: read on: @@ -18,6 +19,48 @@ jobs: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'deploy/konwledge' steps: + - name: Wait for KnowledgeFS CI + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + timeout-minutes: 35 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const workflowId = "knowledge-fs-ci.yml"; + const headBranch = context.payload.workflow_run.head_branch; + const headSha = context.payload.workflow_run.head_sha; + const deadline = Date.now() + 30 * 60 * 1000; + const pollIntervalMs = 15 * 1000; + + while (Date.now() < deadline) { + const { data } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: workflowId, + branch: headBranch, + event: "push", + head_sha: headSha, + per_page: 10, + }); + const run = data.workflow_runs[0]; + + if (!run) { + core.info(`Waiting for ${workflowId} to start for ${headSha}.`); + } else if (run.status !== "completed") { + core.info(`Waiting for ${run.html_url}; current status is ${run.status}.`); + } else if (run.conclusion !== "success") { + throw new Error( + `${workflowId} did not succeed for ${headSha}: ${run.conclusion} (${run.html_url})`, + ); + } else { + core.info(`KnowledgeFS CI succeeded for ${headSha}: ${run.html_url}`); + return; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error(`Timed out waiting for ${workflowId} to succeed for ${headSha}.`); + - name: Deploy to server uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 with: From b97abe53282744a53d064bbee12dd2271e995802 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:19:15 +0800 Subject: [PATCH 098/531] chore: bump nltk from 3.9.4 to 3.10.0 in /api (#39503) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: yunlu.wen --- api/Dockerfile | 4 ++-- .../vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py | 4 ++-- api/pyproject.toml | 2 +- api/uv.lock | 9 +++++---- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/api/Dockerfile b/api/Dockerfile index 1823a8f6a35..311bc51df15 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -99,9 +99,9 @@ ENV VIRTUAL_ENV=/app/api/.venv COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV} ENV PATH="${VIRTUAL_ENV}/bin:${PATH}" -# Download nltk data RUN mkdir -p /usr/local/share/nltk_data \ - && NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.download('punkt'); nltk.download('averaged_perceptron_tagger'); nltk.download('stopwords')" \ + && NLTK_DATA=/usr/local/share/nltk_data python -m nltk.downloader punkt_tab averaged_perceptron_tagger_eng stopwords \ + && NLTK_DATA=/usr/local/share/nltk_data python -c "import nltk; nltk.data.find('tokenizers/punkt_tab'); nltk.data.find('taggers/averaged_perceptron_tagger_eng'); nltk.data.find('corpora/stopwords')" \ && chmod -R 755 /usr/local/share/nltk_data ENV TIKTOKEN_CACHE_DIR=/app/api/.tiktoken_cache diff --git a/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py index b8639dae619..831ccd32009 100644 --- a/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py +++ b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py @@ -316,10 +316,10 @@ class OracleVector(BaseVector): entities.append(current_entity) else: try: - nltk.data.find("tokenizers/punkt") + nltk.data.find("tokenizers/punkt_tab") nltk.data.find("corpora/stopwords") except LookupError: - raise LookupError("Unable to find the required NLTK data package: punkt and stopwords") + raise LookupError("Unable to find the required NLTK data package: punkt_tab and stopwords") e_str = re.sub(r"[^\w ]", "", query) all_tokens = nltk.word_tokenize(e_str) stop_words = stopwords.words("english") diff --git a/api/pyproject.toml b/api/pyproject.toml index 5ebe5d610c4..27070217e63 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -206,7 +206,7 @@ storage = [ ############################################################ # [ Tools ] dependency group ############################################################ -tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"] +tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"] ############################################################ # [ VDB ] workspace plugins — hollow packages under providers/vdb/* diff --git a/api/uv.lock b/api/uv.lock index 676de350ecb..0b1552c4ba9 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1738,7 +1738,7 @@ storage = [ ] tools = [ { name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" }, - { name = "nltk", specifier = ">=3.9.1,<4.0.0" }, + { name = "nltk", specifier = ">=3.10.0,<4.0.0" }, ] trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }] trace-all = [ @@ -4113,17 +4113,18 @@ wheels = [ [[package]] name = "nltk" -version = "3.9.4" +version = "3.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, + { name = "defusedxml" }, { name = "joblib" }, { name = "regex" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" }, + { url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" }, ] [[package]] From ae0b66311d789dd9341edf6f3e0aaf9fde7249ae Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 28 Jul 2026 16:23:42 +0800 Subject: [PATCH 099/531] feat: add missing plugin installation to agent DSL imports (#39680) --- .../agents/__tests__/layout.spec.tsx | 39 ++++ web/app/(commonLayout)/agents/layout.tsx | 8 +- .../workflow/plugin-dependency/index.tsx | 2 + .../agent-composer/__tests__/store.spec.ts | 34 ++++ .../agent-v2/agent-composer/conversions.ts | 21 ++- .../agent-v2/agent-composer/form-state.ts | 2 + .../agent-composer/store-modules/tools.ts | 5 + .../tools/__tests__/index.spec.tsx | 167 +++++++++++++++++- .../components/orchestrate/tools/index.tsx | 149 ++++++++++++++-- .../orchestrate/tools/provider-tool/item.tsx | 111 +++++++++--- 10 files changed, 495 insertions(+), 43 deletions(-) diff --git a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx index 8bcf8a44b5b..edb7035db74 100644 --- a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx +++ b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx @@ -1,10 +1,20 @@ import type { ReactNode } from 'react' +import type { Dependency } from '@/app/components/plugins/types' import { render, screen } from '@testing-library/react' +import { useStore as usePluginDependencyStore } from '@/app/components/workflow/plugin-dependency/store' const mocks = vi.hoisted(() => ({ guardAgentV2Route: vi.fn(), })) +vi.mock('@/app/components/plugins/install-plugin/install-bundle', () => ({ + default: ({ fromDSLPayload }: { fromDSLPayload: Dependency[] }) => ( +
+ {`bundle-size:${fromDSLPayload.length}`} +
+ ), +})) + vi.mock('../feature-guard', () => ({ guardAgentV2Route: () => mocks.guardAgentV2Route(), })) @@ -18,6 +28,7 @@ vi.mock('../agents-access-guard', () => ({ describe('RosterLayout', () => { beforeEach(() => { vi.clearAllMocks() + usePluginDependencyStore.setState({ dependencies: [] }) }) it('should render children when Agent v2 is enabled', async () => { @@ -33,6 +44,34 @@ describe('RosterLayout', () => { expect(screen.getByText('Roster content')).toBeInTheDocument() }) + it('should show the missing-plugin installer across Agent routes', async () => { + usePluginDependencyStore.setState({ + dependencies: [ + { + type: 'marketplace', + value: { + organization: 'langgenius', + plugin: 'sample-plugin', + version: '1.0.0', + plugin_unique_identifier: 'langgenius/sample-plugin:1.0.0', + }, + }, + ], + }) + const { default: RosterLayout } = await import('../layout') + + render( + +
Agent route content
+
, + ) + + expect(screen.getByRole('dialog', { name: 'Install missing plugins' })).toHaveTextContent( + 'bundle-size:1', + ) + expect(screen.getByText('Agent route content')).toBeInTheDocument() + }) + it('should block rendering when the roster guard throws notFound', async () => { mocks.guardAgentV2Route.mockImplementation(() => { throw new Error('NEXT_NOT_FOUND') diff --git a/web/app/(commonLayout)/agents/layout.tsx b/web/app/(commonLayout)/agents/layout.tsx index 5f87a857cb1..ec47488a564 100644 --- a/web/app/(commonLayout)/agents/layout.tsx +++ b/web/app/(commonLayout)/agents/layout.tsx @@ -1,9 +1,15 @@ import type { ReactNode } from 'react' +import PluginDependency from '@/app/components/workflow/plugin-dependency' import { AgentsAccessGuard } from './agents-access-guard' import { guardAgentV2Route } from './feature-guard' export default function Layout({ children }: { children: ReactNode }) { guardAgentV2Route() - return {children} + return ( + + + {children} + + ) } diff --git a/web/app/components/workflow/plugin-dependency/index.tsx b/web/app/components/workflow/plugin-dependency/index.tsx index 1630d516c65..6fe95e0a321 100644 --- a/web/app/components/workflow/plugin-dependency/index.tsx +++ b/web/app/components/workflow/plugin-dependency/index.tsx @@ -1,3 +1,5 @@ +'use client' + import { useCallback } from 'react' import InstallBundle from '@/app/components/plugins/install-plugin/install-bundle' import { useStore } from './store' diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts index ed7615457aa..c4dd9d3dab1 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -381,6 +381,40 @@ describe('agent composer store conversions', () => { }) }) + it('should preserve a plugin tool identity when hydrating and publishing imported config', () => { + const baseConfig = { + tools: { + dify_tools: [ + { + plugin_id: 'langgenius/google', + provider_id: 'langgenius/google/google', + provider_type: 'plugin', + tool_name: 'search', + credential_type: 'unauthorized', + }, + ], + }, + } satisfies AgentSoulConfig + + const formState = agentSoulConfigToFormState(baseConfig) + const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState }) + + expect(formState.tools).toEqual([ + expect.objectContaining({ + id: 'langgenius/google/google', + name: 'google', + pluginId: 'langgenius/google', + }), + ]) + expect(publishConfig.tools?.dify_tools).toEqual([ + expect.objectContaining({ + plugin_id: 'langgenius/google', + provider: 'google', + provider_id: 'langgenius/google/google', + }), + ]) + }) + it('should hydrate legacy secret refs from ref when value is absent', () => { const formState = agentSoulConfigToFormState({ env: { diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts index 4b79f162fce..abc2b698769 100644 --- a/web/features/agent-v2/agent-composer/conversions.ts +++ b/web/features/agent-v2/agent-composer/conversions.ts @@ -197,8 +197,21 @@ const toToolRuntimeParameters = (settings: Record | undefined) return runtimeParameters } +const getDifyToolProviderId = (tool: AgentSoulDifyToolConfig) => + tool.provider_id ?? + (tool.plugin_id && tool.provider + ? `${tool.plugin_id}/${tool.provider}` + : (tool.provider ?? tool.plugin_id ?? '')) + +const getDifyToolProviderName = (tool: AgentSoulDifyToolConfig) => { + if (tool.provider) return tool.provider + + const providerIdSegments = getDifyToolProviderId(tool).split('/').filter(Boolean) + return providerIdSegments.at(-1) ?? '' +} + const getDifyToolActionId = (tool: AgentSoulDifyToolConfig) => - `${tool.provider_id ?? tool.provider ?? tool.plugin_id ?? 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}` + `${getDifyToolProviderId(tool) || 'provider'}:${tool.tool_name ?? tool.name ?? 'tool'}` const toCredentialVariant = (tool: AgentSoulDifyToolConfig) => { const credentialType = tool.credential_type @@ -227,7 +240,7 @@ const toProviderToolFormState = ( const toolSettings: AgentSoulConfigFormState['toolSettings'] = {} for (const tool of config?.tools?.dify_tools ?? []) { - const providerId = tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '' + const providerId = getDifyToolProviderId(tool) const toolName = tool.tool_name ?? tool.name ?? '' if (!providerId || !toolName) continue @@ -249,8 +262,9 @@ const toProviderToolFormState = ( toolByProviderId.set(providerId, { id: providerId, - name: tool.provider ?? providerId, + name: getDifyToolProviderName(tool), kind: 'provider', + pluginId: tool.plugin_id ?? undefined, iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary', providerType: tool.provider_type, allowDelete: @@ -287,6 +301,7 @@ const toDifyToolConfigs = ( enabled: true, provider: tool.name, provider_id: tool.id, + plugin_id: tool.pluginId, provider_type: tool.providerType, tool_name: action.toolName, runtime_parameters: toToolRuntimeParameters(toolSettings[action.id]), diff --git a/web/features/agent-v2/agent-composer/form-state.ts b/web/features/agent-v2/agent-composer/form-state.ts index 76c6e7f3fd5..ad93897b302 100644 --- a/web/features/agent-v2/agent-composer/form-state.ts +++ b/web/features/agent-v2/agent-composer/form-state.ts @@ -93,6 +93,8 @@ type AgentProviderToolCredentialType = 'api-key' | 'oauth2' | 'unauthorized' export type AgentProviderTool = AgentToolBase & { kind: 'provider' displayName?: string + pluginId?: string + pluginUniqueIdentifier?: string iconClassName: string icon?: ToolDefaultValue['provider_icon'] iconDark?: ToolDefaultValue['provider_icon_dark'] diff --git a/web/features/agent-v2/agent-composer/store-modules/tools.ts b/web/features/agent-v2/agent-composer/store-modules/tools.ts index 9cbc98b3722..eaeeac348d3 100644 --- a/web/features/agent-v2/agent-composer/store-modules/tools.ts +++ b/web/features/agent-v2/agent-composer/store-modules/tools.ts @@ -88,6 +88,9 @@ export const addProviderTools = ( nextTools[existingToolIndex] = { ...existingTool, displayName: existingTool.displayName ?? selectedTool.provider_show_name, + pluginId: existingTool.pluginId ?? selectedTool.plugin_id, + pluginUniqueIdentifier: + existingTool.pluginUniqueIdentifier ?? selectedTool.plugin_unique_identifier, icon: existingTool.icon ?? selectedTool.provider_icon, iconDark: existingTool.iconDark ?? selectedTool.provider_icon_dark, allowDelete: existingTool.allowDelete ?? selectedTool.allowDelete, @@ -101,6 +104,8 @@ export const addProviderTools = ( name: selectedTool.provider_name, kind: 'provider', displayName: selectedTool.provider_show_name, + pluginId: selectedTool.plugin_id, + pluginUniqueIdentifier: selectedTool.plugin_unique_identifier, iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary', icon: selectedTool.provider_icon, iconDark: selectedTool.provider_icon_dark, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx index 6419ea4c7c1..48423b89a65 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { AddOAuthButtonProps, Credential } from '@/app/components/plugins/p import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createStore, Provider as JotaiProvider } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,7 +19,7 @@ import { AgentOrchestrateReadOnlyContext } from '../../read-only-context' import { AgentTools } from '../index' const toolProviderState = vi.hoisted(() => ({ - builtInTools: [] as ToolWithProvider[], + builtInTools: [] as ToolWithProvider[] | undefined, })) const pluginAuthState = vi.hoisted(() => ({ canOAuth: true as boolean | undefined, @@ -28,6 +28,21 @@ const pluginAuthState = vi.hoisted(() => ({ notAllowCustomCredential: false, invalidPluginCredentialInfo: vi.fn(), })) +const pluginInstallState = vi.hoisted(() => ({ + manifest: undefined as + | { + label: Record + latest_package_identifier: string + } + | undefined, + fallbackManifest: undefined as + | { + latest_package_identifier: string + } + | undefined, + invalidateBuiltInTools: vi.fn(), + invalidateInstalledPluginList: vi.fn(), +})) vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({ ToolPickerContent: () =>
Mock tool picker
, @@ -48,6 +63,54 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ ), })) +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: ({ + uniqueIdentifier, + onSuccess, + }: { + uniqueIdentifier: string + onSuccess?: () => void + }) => ( + + ), +})) + +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => pluginInstallState.invalidateInstalledPluginList, + useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({ + data: + infos.length > 0 && pluginInstallState.manifest + ? { + data: { + list: infos.map(({ organization, plugin }) => ({ + plugin: { + ...pluginInstallState.manifest, + name: plugin, + plugin_id: `${organization}/${plugin}`, + }, + })), + }, + } + : undefined, + }), + usePluginManifestInfo: (pluginId: string) => ({ + data: + pluginId && pluginInstallState.fallbackManifest + ? { + data: { + plugin: pluginInstallState.fallbackManifest, + }, + } + : undefined, + }), +})) + +vi.mock('@/utils/get-icon', () => ({ + getIconFromMarketPlace: (pluginId: string) => `https://marketplace.example.com/${pluginId}/icon`, +})) + vi.mock('@/app/components/plugins/plugin-auth/authorize/add-oauth-button', () => ({ default: ({ buttonText, onUpdate, renderTrigger }: AddOAuthButtonProps) => { if (renderTrigger) { @@ -99,6 +162,7 @@ vi.mock('@/service/use-tools', () => ({ useAllCustomTools: () => ({ data: [] }), useAllWorkflowTools: () => ({ data: [] }), useAllMCPTools: () => ({ data: [] }), + useInvalidateAllBuiltInTools: () => pluginInstallState.invalidateBuiltInTools, useInvalidToolsByType: () => vi.fn(), })) @@ -181,6 +245,30 @@ const reflectedUnauthorizedNoCredentialDraft = { ], } satisfies AgentSoulConfigFormState +const reflectedUninstalledPluginDraft = { + ...defaultAgentSoulConfigFormState, + tools: [ + { + id: 'langgenius/google/google', + kind: 'provider', + name: 'langgenius/google/google', + pluginId: 'langgenius/google', + iconClassName: 'i-custom-public-other-default-tool-icon', + providerType: 'plugin', + credentialType: 'unauthorized', + credentialVariant: 'unauthorized', + actions: [ + { + id: 'langgenius/google/google:search', + name: 'search', + toolName: 'search', + description: '', + }, + ], + }, + ], +} satisfies AgentSoulConfigFormState + const reflectedUnauthorizedOAuthCredentialTypeDraft = { ...defaultAgentSoulConfigFormState, tools: [ @@ -369,6 +457,10 @@ describe('AgentTools', () => { pluginAuthState.canApiKey = false pluginAuthState.credentials = [] pluginAuthState.notAllowCustomCredential = false + pluginInstallState.manifest = undefined + pluginInstallState.fallbackManifest = undefined + pluginInstallState.invalidateBuiltInTools.mockResolvedValue(undefined) + pluginInstallState.invalidateInstalledPluginList.mockResolvedValue(undefined) }) describe('User Interactions', () => { @@ -528,6 +620,77 @@ describe('AgentTools', () => { expect(screen.getByText('Google Search')).toBeInTheDocument() }) + it('should let users install a missing provider and show its marketplace icon', async () => { + const user = userEvent.setup() + pluginInstallState.manifest = { + label: { + en_US: 'Google Tools', + }, + latest_package_identifier: 'langgenius/google:1.0.0@checksum', + } + renderAgentTools(reflectedUninstalledPluginDraft) + + expect(screen.getByRole('button', { name: 'Google Tools' })).toBeInTheDocument() + expect( + screen.getByText('https://marketplace.example.com/langgenius/google/icon'), + ).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'tools.notAuthorized', + }), + ).not.toBeInTheDocument() + + const installButton = screen.getByRole('button', { + name: 'workflow.nodes.agent.pluginInstaller.install', + }) + expect(installButton).toHaveAttribute( + 'data-unique-identifier', + 'langgenius/google:1.0.0@checksum', + ) + + await user.click(installButton) + + await waitFor(() => { + expect(pluginInstallState.invalidateBuiltInTools).toHaveBeenCalledTimes(1) + expect(pluginInstallState.invalidateInstalledPluginList).toHaveBeenCalledTimes(1) + }) + }) + + it('should keep install actionable when batch marketplace metadata is unavailable', () => { + pluginInstallState.fallbackManifest = { + latest_package_identifier: 'langgenius/google:0.0.1@fallback', + } + renderAgentTools(reflectedUninstalledPluginDraft) + + expect( + screen.getByRole('button', { + name: 'google', + }), + ).toBeInTheDocument() + expect( + screen.getByText('https://marketplace.example.com/langgenius/google/icon'), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { + name: 'workflow.nodes.agent.pluginInstaller.install', + }), + ).toHaveAttribute('data-unique-identifier', 'langgenius/google:0.0.1@fallback') + }) + + it('should wait for the provider catalog before showing an uninstalled status', () => { + toolProviderState.builtInTools = undefined + renderAgentTools(reflectedUnauthorizedNoCredentialDraft) + + expect( + screen.queryByText('plugin.detailPanel.toolSelector.uninstalledTitle'), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: 'tools.notAuthorized', + }), + ).not.toBeInTheDocument() + }) + it('should hide unauthorized status when reflected provider tools do not require credentials', () => { toolProviderState.builtInTools = [duckDuckGoProvider] renderAgentTools(reflectedUnauthorizedNoCredentialDraft) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx index 52d1f11025c..ad1a9b3cd2f 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/index.tsx @@ -1,5 +1,6 @@ 'use client' +import type { MarketplacePlugin } from '@dify/contracts/marketplace' import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { ToolSettingTarget } from './types' import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types' @@ -25,12 +26,18 @@ import { setProviderToolCredentialAtom, } from '@/features/agent-v2/agent-composer/store-modules/tools' import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags' +import { + useFetchPluginsInMarketPlaceByInfo, + useInvalidateInstalledPluginList, +} from '@/service/use-plugins' import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, + useInvalidateAllBuiltInTools, } from '@/service/use-tools' +import { getIconFromMarketPlace } from '@/utils/get-icon' import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context' import { ConfigureSectionAddButton } from '../common/add-button' import { ConfigureSectionEmpty } from '../common/empty' @@ -47,6 +54,12 @@ import { import { ProviderToolSettingsDialog } from './provider-tool/dialog' import { AgentProviderToolItem } from './provider-tool/item' +type DisplayAgentProviderTool = AgentProviderTool & { + isInstalled?: boolean +} + +type DisplayAgentTool = AgentCliTool | DisplayAgentProviderTool + const AgentToolItem = memo( ({ tool, @@ -56,8 +69,9 @@ const AgentToolItem = memo( onDeleteProviderToolAction, onEditCliTool, onCredentialChange, + onPluginInstalled, }: { - tool: AgentTool + tool: DisplayAgentTool onConfigureAction: (target: ToolSettingTarget) => void onDeleteCliTool: (toolId: string) => void onDeleteProviderTool: (toolId: string) => void @@ -68,6 +82,7 @@ const AgentToolItem = memo( credentialId?: string, credentialType?: AgentProviderTool['credentialType'], ) => void + onPluginInstalled: () => void }) => { const [isExpanded, setIsExpanded] = useState(false) @@ -101,12 +116,14 @@ const AgentToolItem = memo( return ( ) } @@ -125,6 +142,7 @@ function useAgentToolProviderMap() { return useMemo(() => { const providers = new Map() + const resolvedProviderTypes = new Set() const buildInToolList = Array.isArray(buildInTools) ? buildInTools : [] const customToolList = Array.isArray(customTools) ? customTools : [] const workflowToolList = Array.isArray(workflowTools) ? workflowTools : [] @@ -136,6 +154,14 @@ function useAgentToolProviderMap() { ...mcpToolList, ] + if (Array.isArray(buildInTools)) { + resolvedProviderTypes.add(CollectionType.builtIn) + resolvedProviderTypes.add('plugin') + } + if (Array.isArray(customTools)) resolvedProviderTypes.add(CollectionType.custom) + if (Array.isArray(workflowTools)) resolvedProviderTypes.add(CollectionType.workflow) + if (Array.isArray(mcpTools)) resolvedProviderTypes.add(CollectionType.mcp) + allProviders.forEach((provider) => { providers.set(provider.id, provider) providers.set(provider.name, provider) @@ -145,14 +171,43 @@ function useAgentToolProviderMap() { } }) - return providers + return { + providerById: providers, + resolvedProviderTypes, + } }, [buildInTools, customTools, workflowTools, mcpTools]) } -function getLocalizedText(text: Record | undefined, language: string) { +function getLocalizedText(text: Partial> | undefined, language: string) { return text?.[language] ?? text?.en_US ?? text?.zh_Hans } +function getProviderPluginId(tool: AgentProviderTool) { + if (tool.pluginId) return tool.pluginId + + if (tool.providerType !== 'plugin' && tool.providerType !== CollectionType.builtIn) return '' + + const providerIdSegments = tool.id.split('/') + if (providerIdSegments.length !== 3) return '' + + return providerIdSegments.slice(0, 2).join('/') +} + +function getProviderDisplayName(tool: AgentProviderTool) { + const providerIdSegments = tool.name.split('/').filter(Boolean) + return providerIdSegments.at(-1) ?? tool.name +} + +function getMarketplacePluginInfo(pluginId: string) { + const [organization, plugin, ...remainingSegments] = pluginId.split('/') + if (!organization || !plugin || remainingSegments.length > 0) return undefined + + return { + organization, + plugin, + } +} + function getProviderCredentialType( provider?: ToolWithProvider, ): AgentProviderTool['credentialType'] { @@ -191,16 +246,42 @@ function getProviderCredentialVariant( : ('unauthorized' as const) } -function useDisplayTools(tools: AgentTool[], providerById: Map) { +function useDisplayTools( + tools: AgentTool[], + providerById: Map, + resolvedProviderTypes: Set, + marketplacePluginById: Map, +) { const language = useGetLanguage() return useMemo(() => { - return tools.map((tool) => { + return tools.map((tool): DisplayAgentTool => { if (tool.kind !== 'provider') return tool const provider = providerById.get(tool.id) ?? providerById.get(tool.name) - if (!provider) return tool + if (!provider) { + const providerPluginId = getProviderPluginId(tool) + const marketplacePlugin = marketplacePluginById.get(providerPluginId) + + return { + ...tool, + isInstalled: resolvedProviderTypes.has(tool.providerType) ? false : undefined, + pluginId: tool.pluginId ?? providerPluginId, + pluginUniqueIdentifier: + tool.pluginUniqueIdentifier ?? marketplacePlugin?.latest_package_identifier, + displayName: + tool.displayName ?? + getLocalizedText(marketplacePlugin?.label ?? marketplacePlugin?.labels, language) ?? + marketplacePlugin?.name ?? + getProviderDisplayName(tool), + icon: + tool.icon ?? + (marketplacePlugin && providerPluginId + ? getIconFromMarketPlace(providerPluginId) + : undefined), + } + } const providerToolByName = new Map( provider.tools.map((providerTool) => [providerTool.name, providerTool]), @@ -209,6 +290,7 @@ function useDisplayTools(tools: AgentTool[], providerById: Map(addToolDefaultView) - const providerById = useAgentToolProviderMap() + const { providerById } = useAgentToolProviderMap() const openToolPicker = useCallback(() => { setView('tool-picker') @@ -402,7 +484,9 @@ export function AgentTools() { const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom) - const providerById = useAgentToolProviderMap() + const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools() + const invalidateInstalledPluginList = useInvalidateInstalledPluginList() + const { providerById, resolvedProviderTypes } = useAgentToolProviderMap() const tools = useAtomValue(agentComposerToolsAtom) const selectedTools = useSelectedProviderTools() const addTools = useSetAtom(addProviderToolsAtom) @@ -432,11 +516,53 @@ export function AgentTools() { }, [setProviderToolCredential], ) + const handlePluginInstalled = useCallback(() => { + void Promise.allSettled([invalidateAllBuiltInTools(), invalidateInstalledPluginList()]) + }, [invalidateAllBuiltInTools, invalidateInstalledPluginList]) const visibleTools = useMemo( () => (ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter((tool) => tool.kind !== 'cli')), [tools], ) - const displayTools = useDisplayTools(visibleTools, providerById) + const missingMarketplacePluginInfos = useMemo(() => { + const pluginIds = new Set() + + visibleTools.forEach((tool) => { + if ( + tool.kind !== 'provider' || + !resolvedProviderTypes.has(tool.providerType) || + providerById.has(tool.id) || + providerById.has(tool.name) + ) + return + + const pluginId = getProviderPluginId(tool) + if (pluginId) pluginIds.add(pluginId) + }) + + return Array.from(pluginIds).flatMap((pluginId) => { + const info = getMarketplacePluginInfo(pluginId) + return info ? [info] : [] + }) + }, [providerById, resolvedProviderTypes, visibleTools]) + const { data: missingMarketplacePluginsData } = useFetchPluginsInMarketPlaceByInfo( + missingMarketplacePluginInfos, + ) + const marketplacePluginById = useMemo( + () => + new Map( + (missingMarketplacePluginsData?.data.list ?? []).map(({ plugin }) => [ + plugin.plugin_id, + plugin, + ]), + ), + [missingMarketplacePluginsData], + ) + const displayTools = useDisplayTools( + visibleTools, + providerById, + resolvedProviderTypes, + marketplacePluginById, + ) /* * knip-ignore-start * Keep this disabled sync logic while backend credential snapshots are being investigated. @@ -558,6 +684,7 @@ export function AgentTools() { onDeleteProviderToolAction={deleteProviderToolAction} onEditCliTool={editCliTool} onCredentialChange={handleProviderCredentialChange} + onPluginInstalled={handlePluginInstalled} /> )) )} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx index ab60f638d66..bd083c85302 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/provider-tool/item.tsx @@ -21,9 +21,12 @@ import { AuthCategory, Authorized, usePluginAuth } from '@/app/components/plugin import AuthorizedInNode from '@/app/components/plugins/plugin-auth/authorized-in-node' import { CollectionType } from '@/app/components/tools/types' import BlockIcon from '@/app/components/workflow/block-icon' +import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button' import { BlockEnum } from '@/app/components/workflow/types' import useTheme from '@/hooks/use-theme' +import { usePluginManifestInfo } from '@/service/use-plugins' import { Theme } from '@/types/app' +import { getIconFromMarketPlace } from '@/utils/get-icon' import { useAgentOrchestrateReadOnly } from '../../read-only-context' function ProviderIcon({ @@ -119,6 +122,36 @@ function UnauthorizedCredentialStatus({ ) } +function UninstalledPluginStatus({ + installInfo, + extraIdentifiers, + onInstall, +}: { + installInfo?: string + extraIdentifiers: string[] + onInstall: () => void +}) { + const { t } = useTranslation() + + if (installInfo) { + return ( + + ) + } + + return ( + + {t(($) => $['detailPanel.toolSelector.uninstalledTitle'], { ns: 'plugin' })} + + + ) +} + function CredentialStatus({ tool, onCredentialChange, @@ -231,19 +264,23 @@ const ProviderToolActionItem = memo( export const AgentProviderToolItem = memo( ({ tool, + isInstalled, isExpanded, onOpenChange, onConfigureAction, onRemoveAction, onRemoveProvider, onCredentialChange, + onInstall, }: { tool: AgentProviderTool + isInstalled?: boolean isExpanded: boolean onOpenChange: (open: boolean) => void onConfigureAction: (target: ToolSettingTarget) => void onRemoveAction: (actionId: string) => void onRemoveProvider: () => void + onInstall: () => void onCredentialChange: ( credentialId?: string, credentialType?: AgentProviderTool['credentialType'], @@ -252,7 +289,20 @@ export const AgentProviderToolItem = memo( const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() const { theme } = useTheme() - const icon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon + const shouldFetchPluginManifest = + isInstalled === false && !!tool.pluginId && !tool.pluginUniqueIdentifier + const { data: pluginManifestData } = usePluginManifestInfo( + shouldFetchPluginManifest ? tool.pluginId! : '', + ) + const pluginManifest = pluginManifestData?.data.plugin + const configuredIcon = theme === Theme.dark && tool.iconDark ? tool.iconDark : tool.icon + const icon = + configuredIcon ?? + (pluginManifest && tool.pluginId ? getIconFromMarketPlace(tool.pluginId) : undefined) + const installInfo = tool.pluginUniqueIdentifier ?? pluginManifest?.latest_package_identifier + const installIdentifiers = [tool.pluginId, tool.id].filter((identifier): identifier is string => + Boolean(identifier), + ) const displayName = tool.displayName ?? tool.name return ( @@ -277,32 +327,41 @@ export const AgentProviderToolItem = memo( {!readOnly && ( - <> - - $['agentDetail.configure.tools.moreActions'], { - name: tool.name, - })} - className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" + + $['agentDetail.configure.tools.moreActions'], { + name: tool.name, + })} + className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover" + > + + {t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })} + + + + + - - {t(($) => $['agentDetail.configure.tools.moreActions'], { name: tool.name })} - - - - - - - {t(($) => $['agentDetail.configure.tools.removeProvider'])} - - - - - + + {t(($) => $['agentDetail.configure.tools.removeProvider'])} + + + + )} + {isInstalled === false && ( +
+ +
+ )} + {!readOnly && isInstalled === true && ( + )}
From 04158ac8ea8e57c109d7df5f325b54121ad36b2d Mon Sep 17 00:00:00 2001 From: Escape0707 Date: Tue, 28 Jul 2026 17:52:12 +0900 Subject: [PATCH 100/531] ci: replace custom noqa markers with guard ignores (#39679) --- api/controllers/common/session.py | 2 +- api/controllers/console/app/workflow.py | 2 +- api/controllers/console/datasets/datasets.py | 4 +- api/controllers/console/datasets/external.py | 2 +- api/controllers/console/explore/trial.py | 2 +- api/fields/conversation_fields.py | 2 +- api/fields/dataset_fields.py | 2 +- api/fields/document_fields.py | 2 +- api/services/app_service.py | 4 +- .../commands/test_check_no_new_getattr.py | 48 ++++++++++++++++--- dify-agent/src/shellctl/__init__.py | 2 +- dify-agent/src/shellctl/shared/__init__.py | 2 +- scripts/ast_grep_guard.py | 6 +-- scripts/check_no_new_controller_sqlalchemy.py | 4 +- scripts/check_no_new_getattr.py | 4 +- scripts/lint_controller_sqlalchemy.py | 2 +- 16 files changed, 63 insertions(+), 27 deletions(-) diff --git a/api/controllers/common/session.py b/api/controllers/common/session.py index 24b1a8729d3..fdffa46b189 100644 --- a/api/controllers/common/session.py +++ b/api/controllers/common/session.py @@ -52,7 +52,7 @@ def with_session[T, **P, R]( session.commit() return result except Exception: - session.rollback() # noqa: no-new-controller-sqlalchemy decorator owns transaction rollback + session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback raise with session_factory.create_session() as session: diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 6025d02fe39..f934f440b0e 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -317,7 +317,7 @@ class _WorkflowResponseSource: self._session = session def __getattr__(self, name: str) -> object: - return getattr(self._workflow, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self._workflow, name) # guard-ignore: no-new-getattr -- delegates model fields @property def created_by_account(self) -> Account | None: diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py index 2ec5a9fe103..19a6c4dc0dc 100644 --- a/api/controllers/console/datasets/datasets.py +++ b/api/controllers/console/datasets/datasets.py @@ -218,7 +218,7 @@ class _DatasetQueryResponseSource: return self.query.get_queries(session=self.session) def __getattr__(self, name: str) -> Any: - return getattr(self.query, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self.query, name) # guard-ignore: no-new-getattr -- delegates model fields class DatasetQueryListResponse(ResponseModel): @@ -257,7 +257,7 @@ class _RelatedAppResponseSource: return self.app.mode_compatible_with_agent_with_session(session=self.session) def __getattr__(self, name: str) -> Any: - return getattr(self.app, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self.app, name) # guard-ignore: no-new-getattr -- delegates model fields class RelatedAppListResponse(ResponseModel): diff --git a/api/controllers/console/datasets/external.py b/api/controllers/console/datasets/external.py index 94efe388561..55867e9cbbb 100644 --- a/api/controllers/console/datasets/external.py +++ b/api/controllers/console/datasets/external.py @@ -106,7 +106,7 @@ class ExternalKnowledgeApiResponseSource: return self.external_knowledge_api.get_dataset_bindings(session=self.session) def __getattr__(self, name: str) -> Any: - return getattr(self.external_knowledge_api, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self.external_knowledge_api, name) # guard-ignore: no-new-getattr -- delegates model fields def external_knowledge_api_response( diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 553e65202ce..d0823f178cb 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -404,7 +404,7 @@ class TrialWorkflowResponseSource: return self.workflow.get_tool_published(session=self.session) def __getattr__(self, name: str) -> Any: - return getattr(self.workflow, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self.workflow, name) # guard-ignore: no-new-getattr -- delegates model fields register_schema_models( diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index 073305d2dd9..7612d09f58d 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -34,7 +34,7 @@ class _SessionResponseSource[SourceT]: self._session = session def __getattr__(self, name: str) -> object: - return getattr(self._source, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self._source, name) # guard-ignore: no-new-getattr -- delegates model fields class _FeedbackResponseSource(_SessionResponseSource[MessageFeedback]): diff --git a/api/fields/dataset_fields.py b/api/fields/dataset_fields.py index 4846aa9689c..c81fb79df3f 100644 --- a/api/fields/dataset_fields.py +++ b/api/fields/dataset_fields.py @@ -227,7 +227,7 @@ class DatasetDetailResponseSource: return self.dataset.get_total_available_documents(session=self.session) def __getattr__(self, name: str) -> Any: - return getattr(self.dataset, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self.dataset, name) # guard-ignore: no-new-getattr -- delegates model fields def dataset_detail_response_source(dataset: Any, *, session: Session) -> DatasetDetailResponseSource: diff --git a/api/fields/document_fields.py b/api/fields/document_fields.py index aa3b4135ec6..16cc49541b7 100644 --- a/api/fields/document_fields.py +++ b/api/fields/document_fields.py @@ -90,7 +90,7 @@ class DocumentWithSession: return self.document.get_doc_metadata_details(session=self.session) def __getattr__(self, name: str) -> Any: - return getattr(self.document, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self.document, name) # guard-ignore: no-new-getattr -- delegates model fields def document_response(document: Document, *, session: Session) -> DocumentResponse: diff --git a/api/services/app_service.py b/api/services/app_service.py index a858163e2fb..6faa88eb114 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -114,7 +114,7 @@ class AppModelConfigResponseView: self._session = session def __getattr__(self, name: str) -> Any: - return getattr(self._app_model_config, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self._app_model_config, name) # guard-ignore: no-new-getattr -- delegates model fields @property def annotation_reply_dict(self) -> Any: @@ -129,7 +129,7 @@ class AppResponseView: self._session = session def __getattr__(self, name: str) -> Any: - return getattr(self._app, name) # noqa: no-new-getattr response adapter delegates model fields + return getattr(self._app, name) # guard-ignore: no-new-getattr -- delegates model fields @property def desc_or_prompt(self) -> str: diff --git a/api/tests/unit_tests/commands/test_check_no_new_getattr.py b/api/tests/unit_tests/commands/test_check_no_new_getattr.py index 4efcb37da92..a63569c706f 100644 --- a/api/tests/unit_tests/commands/test_check_no_new_getattr.py +++ b/api/tests/unit_tests/commands/test_check_no_new_getattr.py @@ -85,6 +85,42 @@ def main_branch_rev(repo: Path) -> str: return git(repo, "rev-parse", "main") +@pytest.mark.parametrize( + ("source_line", "rule_id"), + [ + ( + "value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy", + "no-new-getattr", + ), + ( + "session.rollback() # guard-ignore: no-new-controller-sqlalchemy -- decorator owns rollback", + "no-new-controller-sqlalchemy", + ), + ], +) +def test_has_reasoned_guard_ignore_accepts_custom_rules(source_line: str, rule_id: str) -> None: + module = load_guard_module() + + assert module.has_reasoned_guard_ignore(source_line, rule_id) + + +@pytest.mark.parametrize( + ("source_line", "rule_id"), + [ + ("value = getattr(module, name) # noqa: no-new-getattr legacy marker", "no-new-getattr"), + ("value = getattr(module, name) # guard-ignore: no-new-getattr", "no-new-getattr"), + ( + "value = getattr(module, name) # guard-ignore: another-rule -- wrong rule", + "no-new-getattr", + ), + ], +) +def test_has_reasoned_guard_ignore_rejects_invalid_markers(source_line: str, rule_id: str) -> None: + module = load_guard_module() + + assert not module.has_reasoned_guard_ignore(source_line, rule_id) + + def test_resolve_ast_grep_command_prefers_ast_grep(monkeypatch: pytest.MonkeyPatch) -> None: module = load_guard_module() monkeypatch.setattr( @@ -776,7 +812,7 @@ def test_modified_hunk_with_increased_getattr_count_fails(tmp_path: Path) -> Non assert "net-new getattr" in result.stderr -def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None: +def test_inline_guard_ignore_with_explanatory_text_skips_added_getattr(tmp_path: Path) -> None: init_repo(tmp_path) write_repo_file( tmp_path, @@ -795,20 +831,20 @@ def test_inline_noqa_suppression_with_explanatory_text_skips_added_getattr(tmp_p "pkg/existing.py", """ def read_value(obj): - return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr needed for plugin-defined attributes + return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr -- plugin-defined attributes """, ) commit_all(tmp_path, "add suppressed getattr") result = run_script(tmp_path, "--base-rev", base_rev) - assert "no-new-getattr needed for plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text( + assert "guard-ignore: no-new-getattr -- plugin-defined attributes" in (tmp_path / "pkg/existing.py").read_text( encoding="utf-8" ) assert result.returncode == 0, stderr_lines(result) -def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None: +def test_inline_guard_ignore_without_explanatory_text_is_not_sufficient(tmp_path: Path) -> None: init_repo(tmp_path) write_repo_file( tmp_path, @@ -827,10 +863,10 @@ def test_inline_noqa_without_explanatory_text_is_not_sufficient(tmp_path: Path) "pkg/existing.py", """ def read_value(obj): - return getattr(obj, "dynamic_name", None) # noqa: no-new-getattr + return getattr(obj, "dynamic_name", None) # guard-ignore: no-new-getattr """, ) - commit_all(tmp_path, "add bare noqa getattr") + commit_all(tmp_path, "add bare guard ignore getattr") result = run_script(tmp_path, "--base-rev", base_rev) diff --git a/dify-agent/src/shellctl/__init__.py b/dify-agent/src/shellctl/__init__.py index 3f2237dc690..cb294b365cf 100644 --- a/dify-agent/src/shellctl/__init__.py +++ b/dify-agent/src/shellctl/__init__.py @@ -115,7 +115,7 @@ def __getattr__(name: str) -> Any: if name not in _EXPORTS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") module = import_module(_EXPORTS[name]) - value = getattr(module, name) # noqa: no-new-getattr lazy export proxy + value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy globals()[name] = value return value diff --git a/dify-agent/src/shellctl/shared/__init__.py b/dify-agent/src/shellctl/shared/__init__.py index 5f235b19d9f..83419e4e691 100644 --- a/dify-agent/src/shellctl/shared/__init__.py +++ b/dify-agent/src/shellctl/shared/__init__.py @@ -173,7 +173,7 @@ def __getattr__(name: str) -> Any: if name not in _EXPORTS: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") module = import_module(_EXPORTS[name]) - value = getattr(module, name) # noqa: no-new-getattr lazy export proxy + value = getattr(module, name) # guard-ignore: no-new-getattr -- lazy export proxy globals()[name] = value return value diff --git a/scripts/ast_grep_guard.py b/scripts/ast_grep_guard.py index 3e46486f24e..9f67bfd6d08 100644 --- a/scripts/ast_grep_guard.py +++ b/scripts/ast_grep_guard.py @@ -245,13 +245,13 @@ def extract_meta_variables(raw_match: dict[str, Any]) -> dict[str, str]: return result -def has_reasoned_noqa(source_line: str, rule_id: str) -> bool: - pattern = re.compile(rf"# noqa: {re.escape(rule_id)}(?:\s+(?P\S.*))?\s*$") +def has_reasoned_guard_ignore(source_line: str, rule_id: str) -> bool: + pattern = re.compile(rf"# guard-ignore: {re.escape(rule_id)} -- (?P\S.*)\s*$") match = pattern.search(source_line) if not match: return False reason = match.group("reason") - return reason is not None and bool(reason.strip()) + return bool(reason.strip()) def collect_hunk_violations( diff --git a/scripts/check_no_new_controller_sqlalchemy.py b/scripts/check_no_new_controller_sqlalchemy.py index bf1bea336b7..3812ce8f762 100644 --- a/scripts/check_no_new_controller_sqlalchemy.py +++ b/scripts/check_no_new_controller_sqlalchemy.py @@ -10,7 +10,7 @@ from __future__ import annotations import re from pathlib import Path -from ast_grep_guard import Match, has_reasoned_noqa, rule_path, run_guard +from ast_grep_guard import Match, has_reasoned_guard_ignore, rule_path, run_guard RULE_ID = "no-new-controller-sqlalchemy" @@ -40,7 +40,7 @@ def is_flask_session_get(match: Match) -> bool: def is_suppressed(match: Match) -> bool: - return has_reasoned_noqa(match.source_line, RULE_ID) + return has_reasoned_guard_ignore(match.source_line, RULE_ID) def is_reportable_match(match: Match) -> bool: diff --git a/scripts/check_no_new_getattr.py b/scripts/check_no_new_getattr.py index e788395c62c..9c2248ecf2a 100644 --- a/scripts/check_no_new_getattr.py +++ b/scripts/check_no_new_getattr.py @@ -3,7 +3,7 @@ from __future__ import annotations -from ast_grep_guard import Match, has_reasoned_noqa, is_python_source_path, rule_path, run_guard +from ast_grep_guard import Match, has_reasoned_guard_ignore, is_python_source_path, rule_path, run_guard RULE_ID = "no-new-getattr" @@ -12,7 +12,7 @@ VIOLATION_MESSAGE = "no-new-getattr net-new getattr() in added code" def is_reportable_match(match: Match) -> bool: - return not has_reasoned_noqa(match.source_line, RULE_ID) + return not has_reasoned_guard_ignore(match.source_line, RULE_ID) def main() -> int: diff --git a/scripts/lint_controller_sqlalchemy.py b/scripts/lint_controller_sqlalchemy.py index 87c9582b17f..bef362a42b4 100644 --- a/scripts/lint_controller_sqlalchemy.py +++ b/scripts/lint_controller_sqlalchemy.py @@ -44,7 +44,7 @@ def parse_args() -> argparse.Namespace: help="Files or directories to scan. Defaults to api/controllers.", ) parser.add_argument("--include-allowed", action="store_true", help="Print allowed flush()/commit() findings.") - parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned noqa suppressions.") + parser.add_argument("--include-suppressed", action="store_true", help="Print reasoned guard suppressions.") parser.add_argument("--summary-only", action="store_true", help="Print counts without per-finding details.") parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") parser.add_argument( From eef709e475ba641fd35eb213f0a1a4fb1e374191 Mon Sep 17 00:00:00 2001 From: Escape0707 Date: Tue, 28 Jul 2026 18:10:02 +0900 Subject: [PATCH 101/531] ci: enforce strict checks for unit tests (#39682) --- .../.ruff.toml | 184 ++-- .../pyrefly.toml | 42 +- api/tests/unit_tests/.ruff.toml | 430 ++++++++ api/tests/unit_tests/pyrefly.toml | 961 ++++++++++++++++++ dev/pyrefly-check-local | 14 + 5 files changed, 1493 insertions(+), 138 deletions(-) create mode 100644 api/tests/unit_tests/.ruff.toml create mode 100644 api/tests/unit_tests/pyrefly.toml diff --git a/api/tests/test_containers_integration_tests/.ruff.toml b/api/tests/test_containers_integration_tests/.ruff.toml index 54bc58a79ad..49244091ffb 100644 --- a/api/tests/test_containers_integration_tests/.ruff.toml +++ b/api/tests/test_containers_integration_tests/.ruff.toml @@ -2,114 +2,94 @@ extend = "../../.ruff.toml" src = ["../.."] [lint] -extend-select = ["ANN401", "ARG", "TID251"] +extend-select = ["ANN401", "ARG"] +# Existing strict-mode debt. Remove a file entry when bringing it under strict checking. [lint.per-file-ignores] -"core/rag/pipeline/test_queue_integration.py" = ["ANN401", "TID251", "ARG"] +"controllers/console/test_apikey.py" = ["ARG002"] +"controllers/openapi/test_app_dsl.py" = ["ARG002"] +"controllers/service_api/dataset/test_dataset.py" = ["ARG002"] +"controllers/web/test_conversation.py" = ["ARG002"] +"controllers/web/test_human_input_form.py" = ["ARG001"] +"controllers/web/test_wraps.py" = ["ARG002"] +"core/app/layers/test_pause_state_persist_layer.py" = ["ARG002"] +"core/rag/pipeline/test_queue_integration.py" = ["ARG002", "TID251"] +"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG002"] +"models/test_conversation_message_inputs.py" = ["ARG001"] "models/test_types_enum_text.py" = ["ANN401", "TID251"] -"services/test_app_dsl_service.py" = ["ANN401", "TID251", "ARG"] -"services/test_file_service_zip_and_lookup.py" = ["ANN401", "TID251", "ARG"] -"trigger/conftest.py" = ["ANN401", "TID251"] -"trigger/test_trigger_e2e.py" = ["ANN401", "TID251", "ARG"] -"controllers/console/app/test_app_apis.py" = ["ARG"] -"controllers/console/app/test_app_import_api.py" = ["ARG"] -"controllers/console/auth/test_oauth.py" = ["ARG"] -"controllers/console/auth/test_password_reset.py" = ["ARG"] -"controllers/console/datasets/test_data_source.py" = ["ARG"] -"controllers/console/test_apikey.py" = ["ARG"] -"controllers/console/workspace/test_tool_provider.py" = ["ARG"] -"controllers/mcp/test_mcp.py" = ["ARG"] -"controllers/openapi/test_app_dsl.py" = ["ARG"] -"controllers/openapi/test_workspaces.py" = ["ARG"] -"controllers/service_api/dataset/test_dataset.py" = ["ARG"] -"controllers/web/test_conversation.py" = ["ARG"] -"controllers/web/test_human_input_form.py" = ["ARG"] -"controllers/web/test_wraps.py" = ["ARG"] -"core/app/layers/test_pause_state_persist_layer.py" = ["ARG"] -"core/rag/retrieval/test_dataset_retrieval_integration.py" = ["ARG"] -"models/test_conversation_message_inputs.py" = ["ARG"] -"models/test_conversation_status_count.py" = ["ARG"] -"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG"] -"repositories/test_workflow_run_repository.py" = ["ARG"] -"services/auth/test_api_key_auth_service.py" = ["ARG"] -"services/auth/test_auth_integration.py" = ["ARG"] -"services/dataset_collection_binding.py" = ["ARG"] -"services/dataset_service_update_delete.py" = ["ARG"] -"services/document_service_status.py" = ["ARG"] -"services/enterprise/test_account_deletion_sync.py" = ["ARG"] -"services/plugin/test_plugin_parameter_service.py" = ["ARG"] -"services/plugin/test_plugin_service.py" = ["ARG"] -"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG"] -"services/recommend_app/test_database_retrieval.py" = ["ARG"] -"services/test_account_service.py" = ["ARG"] -"services/test_advanced_prompt_template_service.py" = ["ARG"] -"services/test_annotation_service.py" = ["ARG"] -"services/test_api_based_extension_service.py" = ["ARG"] -"services/test_api_token_service.py" = ["ARG"] -"services/test_app_generate_service.py" = ["ARG"] -"services/test_app_service.py" = ["ARG"] -"services/test_attachment_service.py" = ["ARG"] -"services/test_conversation_variable_updater.py" = ["ARG"] -"services/test_dataset_permission_service.py" = ["ARG"] -"services/test_dataset_service_batch_update_document_status.py" = ["ARG"] -"services/test_dataset_service_retrieval.py" = ["ARG"] -"services/test_delete_archived_workflow_run.py" = ["ARG"] -"services/test_document_service_rename_document.py" = ["ARG"] -"services/test_end_user_service.py" = ["ARG"] -"services/test_feature_service.py" = ["ARG"] -"services/test_feedback_service.py" = ["ARG"] -"services/test_file_service.py" = ["ARG"] -"services/test_human_input_delivery_test_service.py" = ["ARG"] -"services/test_message_service.py" = ["ARG"] -"services/test_messages_clean_service.py" = ["ARG", "S110"] -"services/test_metadata_partial_update.py" = ["ARG"] -"services/test_metadata_service.py" = ["ARG"] -"services/test_model_load_balancing_service.py" = ["ARG"] -"services/test_model_provider_service.py" = ["ARG"] -"services/test_oauth_server_service.py" = ["ARG"] -"services/test_ops_service.py" = ["ARG"] -"services/test_saved_message_service.py" = ["ARG"] -"services/test_web_conversation_service.py" = ["ARG"] -"services/test_webapp_auth_service.py" = ["ARG"] -"services/test_webhook_service.py" = ["ARG"] -"services/test_workflow_app_service.py" = ["ARG"] -"services/test_workflow_draft_variable_service.py" = ["ARG"] -"services/test_workflow_run_service.py" = ["ARG"] -"services/test_workflow_service.py" = ["ARG"] -"services/test_workspace_service.py" = ["ARG"] -"services/tools/test_api_tools_manage_service.py" = ["ARG"] -"services/tools/test_mcp_tools_manage_service.py" = ["ARG"] -"services/tools/test_tools_transform_service.py" = ["ARG"] -"services/workflow/test_workflow_converter.py" = ["ARG"] -"tasks/test_add_document_to_index_task.py" = ["ARG"] -"tasks/test_batch_clean_document_task.py" = ["ARG"] -"tasks/test_batch_create_segment_to_index_task.py" = ["ARG"] +"repositories/test_sqlalchemy_api_workflow_run_repository.py" = ["ARG002", "ARG005"] +"repositories/test_workflow_run_repository.py" = ["ARG002"] +"services/auth/test_auth_integration.py" = ["ARG002"] +"services/dataset_collection_binding.py" = ["ARG002"] +"services/document_service_status.py" = ["ARG002"] +"services/rag_pipeline/test_rag_pipeline_service_db.py" = ["ARG002"] +"services/recommend_app/test_database_retrieval.py" = ["ARG002"] +"services/test_account_service.py" = ["ARG002"] +"services/test_advanced_prompt_template_service.py" = ["ARG002"] +"services/test_app_dsl_service.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"] +"services/test_app_service.py" = ["ARG002"] +"services/test_attachment_service.py" = ["ARG002"] +"services/test_conversation_variable_updater.py" = ["ARG002"] +"services/test_dataset_service_batch_update_document_status.py" = ["ARG002"] +"services/test_delete_archived_workflow_run.py" = ["ARG002"] +"services/test_document_service_rename_document.py" = ["ARG001"] +"services/test_end_user_service.py" = ["ARG002"] +"services/test_feature_service.py" = ["ARG002"] +"services/test_file_service.py" = ["ARG002"] +"services/test_file_service_zip_and_lookup.py" = ["TID251"] +"services/test_messages_clean_service.py" = ["ARG002", "S110"] +"services/test_metadata_partial_update.py" = ["ARG002"] +"services/test_metadata_service.py" = ["ARG002"] +"services/test_model_load_balancing_service.py" = ["ARG002"] +"services/test_model_provider_service.py" = ["ARG002"] +"services/test_ops_service.py" = ["ARG002"] +"services/test_webapp_auth_service.py" = ["ARG002"] +"services/test_webhook_service.py" = ["ARG002"] +"services/test_workflow_draft_variable_service.py" = ["ARG002"] +"services/test_workflow_run_service.py" = ["ARG002"] +"services/test_workflow_service.py" = ["ARG002"] +"services/test_workspace_service.py" = ["ARG002"] +"services/tools/test_api_tools_manage_service.py" = ["ARG002"] +"services/tools/test_mcp_tools_manage_service.py" = ["ARG002", "ARG005"] +"services/tools/test_tools_transform_service.py" = ["ARG002"] +"services/workflow/test_workflow_converter.py" = ["ARG002"] +"tasks/test_add_document_to_index_task.py" = ["ARG002"] +"tasks/test_batch_clean_document_task.py" = ["ARG002"] +"tasks/test_batch_create_segment_to_index_task.py" = ["ARG001", "ARG002"] "tasks/test_clean_dataset_task.py" = ["T201"] -"tasks/test_clean_notion_document_task.py" = ["ARG"] -"tasks/test_create_segment_to_index_task.py" = ["ARG"] -"tasks/test_dataset_indexing_task.py" = ["ARG"] -"tasks/test_deal_dataset_vector_index_task.py" = ["ARG"] -"tasks/test_delete_segment_from_index_task.py" = ["ARG"] -"tasks/test_disable_segment_from_index_task.py" = ["ARG"] -"tasks/test_disable_segments_from_index_task.py" = ["ARG"] -"tasks/test_document_indexing_sync_task.py" = ["ARG"] -"tasks/test_document_indexing_task.py" = ["ARG"] -"tasks/test_document_indexing_update_task.py" = ["ARG"] -"tasks/test_duplicate_document_indexing_task.py" = ["ARG"] -"tasks/test_enable_segments_to_index_task.py" = ["ARG"] -"tasks/test_mail_change_mail_task.py" = ["ARG"] -"tasks/test_mail_email_code_login_task.py" = ["ARG"] -"tasks/test_mail_human_input_delivery_task.py" = ["ARG"] -"tasks/test_mail_inner_task.py" = ["ARG"] -"tasks/test_mail_invite_member_task.py" = ["ARG"] -"tasks/test_mail_owner_transfer_task.py" = ["ARG"] -"tasks/test_mail_register_task.py" = ["ARG"] -"tasks/test_rag_pipeline_run_tasks.py" = ["ARG"] +"tasks/test_clean_notion_document_task.py" = ["ARG002"] +"tasks/test_create_segment_to_index_task.py" = ["ARG002"] +"tasks/test_dataset_indexing_task.py" = ["ARG002"] +"tasks/test_deal_dataset_vector_index_task.py" = ["ARG002"] +"tasks/test_delete_segment_from_index_task.py" = ["ARG002"] +"tasks/test_disable_segment_from_index_task.py" = ["ARG002"] +"tasks/test_disable_segments_from_index_task.py" = ["ARG002"] +"tasks/test_document_indexing_sync_task.py" = ["ARG002"] +"tasks/test_document_indexing_task.py" = ["ARG002"] +"tasks/test_document_indexing_update_task.py" = ["ARG002"] +"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"] +"tasks/test_enable_segments_to_index_task.py" = ["ARG002"] +"tasks/test_mail_change_mail_task.py" = ["ARG002"] +"tasks/test_mail_email_code_login_task.py" = ["ARG002"] +"tasks/test_mail_human_input_delivery_task.py" = ["ARG001"] +"tasks/test_mail_inner_task.py" = ["ARG002"] +"tasks/test_mail_invite_member_task.py" = ["ARG002"] +"tasks/test_mail_owner_transfer_task.py" = ["ARG002"] +"tasks/test_mail_register_task.py" = ["ARG002"] +"tasks/test_rag_pipeline_run_tasks.py" = ["ARG002"] "test_workflow_pause_integration.py" = ["T201"] -"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG"] -"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG"] -"workflow/nodes/code_executor/test_code_python3.py" = ["ARG"] +"trigger/conftest.py" = ["ANN401", "TID251"] +"trigger/test_trigger_e2e.py" = ["ANN401", "ARG001", "TID251"] +"workflow/nodes/code_executor/test_code_javascript.py" = ["ARG002"] +"workflow/nodes/code_executor/test_code_jinja2.py" = ["ARG002"] +"workflow/nodes/code_executor/test_code_python3.py" = ["ARG002"] "workflow/nodes/code_executor/test_utils.py" = ["T201"] +[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"] +msg = "Use Pydantic payload/query models instead of reqparse." + +[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"] +msg = "Use Pydantic payload/query models instead of reqparse." + [lint.flake8-tidy-imports.banned-api."typing.Any"] msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead." diff --git a/api/tests/test_containers_integration_tests/pyrefly.toml b/api/tests/test_containers_integration_tests/pyrefly.toml index e73d5bbe117..a02e394465a 100644 --- a/api/tests/test_containers_integration_tests/pyrefly.toml +++ b/api/tests/test_containers_integration_tests/pyrefly.toml @@ -1,42 +1,24 @@ preset = "strict" -strict-callable-subtyping = true project-includes = ["."] search-path = ["../.."] +python-platform = "linux" +python-version = "3.12.0" +infer-with-first-use = true +min-severity = "warn" -# Verify project-excludes from the repo root: -# tmp_config=$(mktemp --tmpdir=api/tests/test_containers_integration_tests pyrefly-no-excludes.XXXXXX.toml) -# awk 'BEGIN {skip=0} /^project-excludes = \[/ {skip=1; next} skip && /^\]/ {skip=0; next} !skip {print}' api/tests/test_containers_integration_tests/pyrefly.toml > "$tmp_config" -# tmp_name=$(basename "$tmp_config") -# comm -3 <(sed -n 's/^ "\(.*\)",$/\1/p' api/tests/test_containers_integration_tests/pyrefly.toml | sort) <(uv --directory api run pyrefly check --config "tests/test_containers_integration_tests/$tmp_name" --summary=none --output-format=min-text 2>/dev/null | rg '^ERROR ' | sed -E 's#^ERROR (tests/test_containers_integration_tests/[^:]+):.*#\1#' | sed 's#^tests/test_containers_integration_tests/##' | sort -u) -# rm --force "$tmp_config" +# Existing strict-mode debt. Remove a file when bringing it under strict checking. project-excludes = [ "commands/test_legacy_model_type_migration.py", - "controllers/console/app/test_app_apis.py", - "controllers/console/app/test_app_import_api.py", "controllers/console/app/test_chat_conversation_status_count_api.py", "controllers/console/app/test_conversation_read_timestamp.py", "controllers/console/app/test_workflow_draft_variable.py", - "controllers/console/auth/test_email_register.py", - "controllers/console/auth/test_forgot_password.py", - "controllers/console/auth/test_oauth.py", - "controllers/console/auth/test_password_reset.py", - "controllers/console/datasets/rag_pipeline/test_rag_pipeline.py", - "controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py", - "controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py", - "controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py", - "controllers/console/datasets/test_data_source.py", - "controllers/console/explore/test_conversation.py", "controllers/console/test_api_based_extension.py", "controllers/console/test_apikey.py", - "controllers/console/workspace/test_tool_provider.py", - "controllers/console/workspace/test_trigger_providers.py", "controllers/console/workspace/test_workspace_wraps.py", - "controllers/mcp/test_mcp.py", "controllers/service_api/dataset/test_dataset.py", "controllers/service_api/test_site.py", "controllers/web/test_conversation.py", "controllers/web/test_site.py", - "controllers/web/test_web_forgot_password.py", "controllers/web/test_wraps.py", "core/app/layers/test_pause_state_persist_layer.py", "core/rag/pipeline/test_queue_integration.py", @@ -57,22 +39,16 @@ project-excludes = [ "repositories/test_sqlalchemy_execution_extra_content_repository.py", "repositories/test_sqlalchemy_workflow_node_execution_repository.py", "repositories/test_workflow_run_repository.py", - "services/auth/test_api_key_auth_service.py", "services/auth/test_auth_integration.py", "services/dataset_collection_binding.py", "services/dataset_service_update_delete.py", "services/document_service_status.py", - "services/enterprise/test_account_deletion_sync.py", - "services/plugin/test_plugin_parameter_service.py", - "services/plugin/test_plugin_service.py", - "services/rag_pipeline/test_rag_pipeline_service_db.py", "services/recommend_app/test_database_retrieval.py", "services/test_account_service.py", "services/test_advanced_prompt_template_service.py", "services/test_agent_service.py", "services/test_annotation_service.py", "services/test_api_based_extension_service.py", - "services/test_api_token_service.py", "services/test_app_dsl_service.py", "services/test_app_generate_service.py", "services/test_app_service.py", @@ -97,20 +73,15 @@ project-excludes = [ "services/test_document_service_rename_document.py", "services/test_end_user_service.py", "services/test_feature_service.py", - "services/test_feedback_service.py", "services/test_file_service.py", "services/test_human_input_delivery_test.py", - "services/test_human_input_delivery_test_service.py", "services/test_message_export_service.py", "services/test_message_service.py", "services/test_message_service_execution_extra_content.py", "services/test_message_service_extra_contents.py", "services/test_messages_clean_service.py", - "services/test_metadata_partial_update.py", - "services/test_metadata_service.py", "services/test_model_load_balancing_service.py", "services/test_model_provider_service.py", - "services/test_oauth_server_service.py", "services/test_ops_service.py", "services/test_restore_archived_workflow_run.py", "services/test_saved_message_service.py", @@ -124,7 +95,6 @@ project-excludes = [ "services/test_workflow_run_service.py", "services/test_workflow_service.py", "services/test_workspace_service.py", - "services/tools/test_api_tools_manage_service.py", "services/tools/test_mcp_tools_manage_service.py", "services/tools/test_tools_transform_service.py", "services/tools/test_workflow_tools_manage_service.py", @@ -161,7 +131,6 @@ project-excludes = [ "test_workflow_pause_integration.py", "trigger/conftest.py", "trigger/test_trigger_e2e.py", - "workflow/nodes/code_executor/test_code_executor.py", "workflow/nodes/code_executor/test_code_javascript.py", "workflow/nodes/code_executor/test_code_jinja2.py", "workflow/nodes/code_executor/test_code_python3.py", @@ -169,6 +138,7 @@ project-excludes = [ ] [errors] +missing-override-decorator = "error" redundant-cast = true unannotated-return = true unnecessary-type-conversion = true diff --git a/api/tests/unit_tests/.ruff.toml b/api/tests/unit_tests/.ruff.toml new file mode 100644 index 00000000000..65c2cff31c0 --- /dev/null +++ b/api/tests/unit_tests/.ruff.toml @@ -0,0 +1,430 @@ +extend = "../../.ruff.toml" +src = ["../.."] + +[lint] +extend-select = ["ANN401", "ARG"] + +# Existing strict-mode debt. Remove a file entry when bringing it under strict checking. +[lint.per-file-ignores] +"clients/agent_backend/test_request_builder.py" = ["TID251"] +"commands/test_archive_workflow_runs.py" = ["ARG005"] +"commands/test_data_migration_wizard.py" = ["ARG005"] +"commands/test_legacy_model_type_migration.py" = ["ARG001", "ARG002", "ARG005"] +"controllers/common/test_agent_app_parameters.py" = ["ARG005", "TID251"] +"controllers/common/test_app_access.py" = ["ARG005"] +"controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"] +"controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"] +"controllers/console/app/test_agent_config_inspector.py" = ["ARG005"] +"controllers/console/app/test_agent_drive_inspector.py" = ["ARG005"] +"controllers/console/app/test_agent_manage_guard.py" = ["ARG001"] +"controllers/console/app/test_agent_skills.py" = ["ARG005"] +"controllers/console/app/test_annotation_security.py" = ["ARG002"] +"controllers/console/app/test_app_apis.py" = ["ARG001", "ARG002"] +"controllers/console/app/test_app_import_api.py" = ["ARG001", "ARG002", "ARG005"] +"controllers/console/app/test_app_response_models.py" = ["ARG002", "ARG004", "ARG005"] +"controllers/console/app/test_conversation_api.py" = ["ARG001"] +"controllers/console/app/test_generator_api.py" = ["ARG001"] +"controllers/console/app/test_mcp_server_response.py" = ["ARG002"] +"controllers/console/app/test_message_api.py" = ["ARG001"] +"controllers/console/app/test_statistic_api.py" = ["ANN401", "ARG001", "TID251"] +"controllers/console/app/test_workflow.py" = ["ARG001", "ARG005"] +"controllers/console/app/test_workflow_convert_api.py" = ["ARG005"] +"controllers/console/app/test_workflow_node_output_inspector.py" = ["ANN401", "ARG001", "TID251"] +"controllers/console/app/test_workflow_run_api.py" = ["ANN401", "TID251"] +"controllers/console/app/workflow_draft_variables_test.py" = ["TID251"] +"controllers/console/auth/test_account_activation.py" = ["ARG002"] +"controllers/console/auth/test_authentication_security.py" = ["ARG002"] +"controllers/console/auth/test_email_verification.py" = ["ARG002"] +"controllers/console/auth/test_login_logout.py" = ["ARG002"] +"controllers/console/auth/test_oauth.py" = ["ARG002"] +"controllers/console/auth/test_oauth_timezone.py" = ["ARG001"] +"controllers/console/auth/test_password_reset.py" = ["ARG002"] +"controllers/console/auth/test_token_refresh.py" = ["ARG002"] +"controllers/console/billing/test_billing.py" = ["ARG002"] +"controllers/console/datasets/test_datasets.py" = ["ARG005"] +"controllers/console/datasets/test_datasets_document.py" = ["ARG002", "ARG005"] +"controllers/console/datasets/test_datasets_document_download.py" = ["ARG005"] +"controllers/console/datasets/test_datasets_segments.py" = ["TID251"] +"controllers/console/datasets/test_external.py" = ["TID251"] +"controllers/console/datasets/test_wraps.py" = ["ARG001"] +"controllers/console/explore/test_trial.py" = ["ANN401", "ARG001", "ARG005", "TID251"] +"controllers/console/explore/test_wraps.py" = ["ARG001"] +"controllers/console/snippets/test_snippet_workflow.py" = ["ARG001"] +"controllers/console/tag/test_tags.py" = ["ARG002"] +"controllers/console/test_files.py" = ["ARG001", "ARG002"] +"controllers/console/test_human_input_form.py" = ["ARG001", "ARG005"] +"controllers/console/test_init_validate.py" = ["ARG005"] +"controllers/console/test_workspace_account.py" = ["ARG002"] +"controllers/console/test_workspace_members.py" = ["ARG002", "ARG005"] +"controllers/console/test_wraps.py" = ["ARG001", "ARG002"] +"controllers/console/workspace/test_load_balancing_config.py" = ["ARG001"] +"controllers/console/workspace/test_plugin.py" = ["ARG002", "TID251"] +"controllers/console/workspace/test_snippets.py" = ["ARG002"] +"controllers/console/workspace/test_tool_providers.py" = ["ARG001", "ARG005"] +"controllers/console/workspace/test_trigger_providers.py" = ["ARG001"] +"controllers/console/workspace/test_workspace.py" = ["ARG005"] +"controllers/files/test_image_preview.py" = ["ARG005"] +"controllers/files/test_tool_files.py" = ["ARG002", "ARG005"] +"controllers/files/test_upload.py" = ["ARG002", "ARG005"] +"controllers/inner_api/plugin/test_plugin.py" = ["ARG002"] +"controllers/inner_api/plugin/test_plugin_wraps.py" = ["ARG001", "ARG002", "ARG003", "TID251"] +"controllers/inner_api/test_runtime_credentials.py" = ["ARG001"] +"controllers/mcp/test_mcp.py" = ["ARG002"] +"controllers/openapi/auth/test_conditions.py" = ["ARG005"] +"controllers/openapi/auth/test_flow.py" = ["ARG005"] +"controllers/openapi/auth/test_pipeline.py" = ["ARG001"] +"controllers/openapi/conftest.py" = ["ARG001"] +"controllers/openapi/test_account.py" = ["ARG005"] +"controllers/openapi/test_app_describe_builder.py" = ["ARG001"] +"controllers/openapi/test_app_run_streaming.py" = ["ARG001"] +"controllers/openapi/test_contract.py" = ["ARG001", "TID251"] +"controllers/openapi/test_error_contract.py" = ["ARG002"] +"controllers/openapi/test_human_input_form.py" = ["ARG002"] +"controllers/openapi/test_oauth_sso_claims.py" = ["ARG002"] +"controllers/openapi/test_workflow_events_openapi.py" = ["ARG002", "ARG005"] +"controllers/openapi/test_workspaces_members.py" = ["ARG001"] +"controllers/service_api/app/test_app.py" = ["ARG002"] +"controllers/service_api/app/test_completion.py" = ["ARG002"] +"controllers/service_api/app/test_file.py" = ["ARG002"] +"controllers/service_api/app/test_hitl_service_api.py" = ["ARG002", "ARG005"] +"controllers/service_api/app/test_workflow_events.py" = ["ARG005"] +"controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py" = ["ARG002"] +"controllers/service_api/dataset/test_dataset_segment.py" = ["ARG002"] +"controllers/service_api/dataset/test_document.py" = ["ARG001", "ARG002"] +"controllers/service_api/dataset/test_metadata.py" = ["ARG002"] +"controllers/service_api/test_trace_session_id_parsing.py" = ["ARG001"] +"controllers/service_api/test_wraps.py" = ["ARG001", "ARG002"] +"controllers/trigger/test_trigger.py" = ["ARG002"] +"controllers/trigger/test_webhook.py" = ["ARG002"] +"controllers/web/conftest.py" = ["ANN401", "TID251"] +"controllers/web/test_app.py" = ["ARG002", "ARG005"] +"controllers/web/test_audio.py" = ["ARG002"] +"controllers/web/test_completion.py" = ["ARG002"] +"controllers/web/test_feature.py" = ["ARG002"] +"controllers/web/test_human_input_form.py" = ["ARG001", "ARG002", "ARG005"] +"controllers/web/test_message_endpoints.py" = ["ARG002"] +"controllers/web/test_remote_files.py" = ["ARG002"] +"controllers/web/test_saved_message.py" = ["ARG002"] +"controllers/web/test_web_login.py" = ["ARG002"] +"controllers/web/test_web_passport.py" = ["ARG002"] +"controllers/web/test_workflow.py" = ["ARG002"] +"core/agent/test_base_agent_runner.py" = ["ARG002"] +"core/agent/test_cot_agent_runner.py" = ["ARG001"] +"core/agent/test_cot_chat_agent_runner.py" = ["ARG002"] +"core/agent/test_fc_agent_runner.py" = ["TID251"] +"core/app/app_config/common/test_parameters_mapping.py" = ["ARG002"] +"core/app/app_config/easy_ui_based_app/test_dataset_manager.py" = ["ARG001", "ARG002"] +"core/app/app_config/easy_ui_based_app/test_model_config_converter.py" = ["ARG002"] +"core/app/app_config/easy_ui_based_app/test_variables_manager.py" = ["ARG002"] +"core/app/apps/advanced_chat/test_app_generator.py" = ["ARG001", "ARG002", "ARG005"] +"core/app/apps/advanced_chat/test_app_runner_input_moderation.py" = ["ARG001", "ARG005"] +"core/app/apps/advanced_chat/test_generate_task_pipeline.py" = ["ARG005"] +"core/app/apps/advanced_chat/test_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"] +"core/app/apps/agent_app/test_app_generator.py" = ["ARG001", "ARG005"] +"core/app/apps/agent_app/test_app_runner.py" = ["ANN401", "ARG002", "ARG005", "TID251"] +"core/app/apps/agent_app/test_input_guards.py" = ["ANN401", "ARG002", "TID251"] +"core/app/apps/agent_app/test_resolve_agent.py" = ["ANN401", "TID251"] +"core/app/apps/agent_app/test_runtime_request_builder.py" = ["ARG002", "TID251"] +"core/app/apps/agent_chat/test_agent_chat_app_config_manager.py" = ["ARG005"] +"core/app/apps/agent_chat/test_agent_chat_app_generator.py" = ["ARG001"] +"core/app/apps/chat/test_app_config_manager.py" = ["ARG001"] +"core/app/apps/chat/test_app_generator_and_runner.py" = ["ARG001", "ARG002", "ARG005"] +"core/app/apps/common/test_workflow_response_converter_truncation.py" = ["TID251"] +"core/app/apps/completion/test_app_runner.py" = ["ARG001", "ARG002", "ARG005"] +"core/app/apps/pipeline/test_pipeline_generator.py" = ["ARG001", "ARG002", "ARG005"] +"core/app/apps/pipeline/test_pipeline_runner.py" = ["ARG001", "ARG002"] +"core/app/apps/test_advanced_chat_app_generator.py" = ["ARG001"] +"core/app/apps/test_base_app_generator.py" = ["ARG005"] +"core/app/apps/test_base_app_runner.py" = ["ARG001", "ARG002", "ARG005"] +"core/app/apps/test_pause_resume.py" = ["ANN401", "TID251"] +"core/app/apps/test_streaming_utils.py" = ["ARG001"] +"core/app/apps/test_workflow_app_generator.py" = ["ARG005"] +"core/app/apps/test_workflow_app_runner_core.py" = ["ARG001", "ARG002", "ARG004", "ARG005"] +"core/app/apps/test_workflow_app_runner_single_node.py" = ["ANN401", "TID251"] +"core/app/apps/test_workflow_pause_events.py" = ["ARG005"] +"core/app/apps/workflow/test_app_generator_extra.py" = ["ARG005"] +"core/app/apps/workflow/test_generate_task_pipeline_core.py" = ["ARG002", "ARG005"] +"core/app/features/rate_limiting/test_rate_limit.py" = ["ARG001"] +"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py" = ["ARG002"] +"core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py" = ["ARG001", "ARG002", "ARG005"] +"core/app/task_pipeline/test_message_cycle_manager_optimization.py" = ["ARG002"] +"core/app/test_easy_ui_model_config_manager.py" = ["ARG005"] +"core/app/workflow/layers/test_persistence_inspector_publish.py" = ["ANN401", "ARG005", "TID251"] +"core/app/workflow/test_file_runtime.py" = ["ARG001", "ARG005"] +"core/app/workflow/test_observability_layer_extra.py" = ["ARG005"] +"core/app/workflow/test_persistence_layer.py" = ["ARG001"] +"core/base/test_app_generator_tts_publisher.py" = ["ARG002"] +"core/callback_handler/test_agent_tool_callback_handler.py" = ["ARG002"] +"core/callback_handler/test_workflow_tool_callback_handler.py" = ["ARG002"] +"core/datasource/__base/test_datasource_provider.py" = ["ARG002"] +"core/datasource/test_datasource_file_manager.py" = ["ARG001", "ARG002"] +"core/datasource/test_notion_provider.py" = ["ARG002", "TID251"] +"core/datasource/test_website_crawl.py" = ["ARG002"] +"core/datasource/utils/test_message_transformer.py" = ["ARG002"] +"core/entities/test_entities_mcp_provider.py" = ["ARG001"] +"core/entities/test_entities_provider_configuration.py" = ["ANN401", "ARG001", "ARG005", "TID251"] +"core/extension/test_extensible.py" = ["ARG002", "ARG005"] +"core/external_data_tool/api/test_api.py" = ["ARG001"] +"core/external_data_tool/test_base.py" = ["TID251"] +"core/external_data_tool/test_external_data_fetch.py" = ["ARG001"] +"core/helper/code_executor/test_code_executor.py" = ["TID251"] +"core/helper/code_executor/test_template_transformer.py" = ["ANN401", "TID251"] +"core/llm_generator/test_llm_generator.py" = ["ARG002"] +"core/mcp/auth/test_auth_flow.py" = ["ARG002"] +"core/mcp/client/test_session.py" = ["ARG001", "TID251"] +"core/mcp/client/test_sse.py" = ["ARG001", "TID251"] +"core/mcp/client/test_streamable_http.py" = ["ARG001", "ARG005", "S110", "TID251"] +"core/mcp/session/test_base_session.py" = ["S110"] +"core/mcp/session/test_client_session.py" = ["ARG005"] +"core/mcp/test_mcp_client.py" = ["ARG002"] +"core/memory/test_token_buffer_memory.py" = ["ARG002"] +"core/moderation/test_content_moderation.py" = ["TID251"] +"core/moderation/test_output_moderation.py" = ["ARG001", "ARG002"] +"core/ops/test_base_trace_instance.py" = ["ARG001"] +"core/ops/test_lookup_helpers.py" = ["ARG002"] +"core/ops/test_ops_trace_manager.py" = ["ARG001", "ARG002", "ARG005"] +"core/ops/test_trace_queue_manager.py" = ["ARG004"] +"core/ops/test_trace_session_metadata.py" = ["ARG001", "ARG005"] +"core/plugin/impl/test_agent_client.py" = ["ARG001"] +"core/plugin/impl/test_datasource_manager.py" = ["ARG001"] +"core/plugin/impl/test_oauth_handler.py" = ["ARG001"] +"core/plugin/impl/test_tool_manager.py" = ["ARG001"] +"core/plugin/impl/test_trigger_client.py" = ["ARG001"] +"core/plugin/test_endpoint_client.py" = ["ARG002"] +"core/plugin/test_model_runtime_adapter.py" = ["ARG002"] +"core/plugin/test_plugin_runtime.py" = ["ARG001", "ARG002", "TID251"] +"core/prompt/test_advanced_prompt_transform.py" = ["ARG005"] +"core/prompt/test_prompt_transform.py" = ["ARG005"] +"core/rag/datasource/keyword/jieba/test_jieba.py" = ["ARG001", "TID251"] +"core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py" = ["ARG004"] +"core/rag/datasource/keyword/test_keyword_factory.py" = ["ARG005"] +"core/rag/datasource/test_datasource_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"] +"core/rag/datasource/test_retrieval_attachment_access.py" = ["ARG005"] +"core/rag/datasource/vdb/test_vector_factory.py" = ["ARG002"] +"core/rag/embedding/test_embedding_base.py" = ["TID251"] +"core/rag/embedding/test_embedding_service.py" = ["ARG001"] +"core/rag/extractor/firecrawl/test_firecrawl.py" = ["TID251"] +"core/rag/extractor/test_csv_extractor.py" = ["ARG001", "ARG005"] +"core/rag/extractor/test_excel_extractor.py" = ["ARG002", "ARG005"] +"core/rag/extractor/test_extract_processor.py" = ["ARG001", "ARG005"] +"core/rag/extractor/test_helpers.py" = ["ARG002"] +"core/rag/extractor/test_markdown_extractor.py" = ["ARG001"] +"core/rag/extractor/test_notion_extractor.py" = ["ARG002", "ARG005"] +"core/rag/extractor/test_pdf_extractor.py" = ["ARG001", "ARG005"] +"core/rag/extractor/test_text_extractor.py" = ["ARG001"] +"core/rag/extractor/test_word_extractor.py" = ["ARG001", "ARG002", "ARG005"] +"core/rag/extractor/unstructured/test_unstructured_extractors.py" = ["ARG001", "ARG005"] +"core/rag/extractor/watercrawl/test_watercrawl.py" = ["ARG001", "ARG005", "TID251"] +"core/rag/indexing/processor/conftest.py" = ["ANN401", "ARG002", "TID251"] +"core/rag/indexing/processor/test_paragraph_index_processor.py" = ["ARG002", "TID251"] +"core/rag/indexing/processor/test_qa_index_processor.py" = ["ARG001", "TID251"] +"core/rag/indexing/test_index_processor.py" = ["ARG005"] +"core/rag/indexing/test_indexing_runner.py" = ["ARG005", "TID251"] +"core/rag/pipeline/test_queue.py" = ["ARG002"] +"core/rag/retrieval/test_dataset_retrieval.py" = ["ARG001", "ARG002", "ARG005", "TID251"] +"core/rag/splitter/test_text_splitter.py" = ["ARG002", "ARG005"] +"core/repositories/test_celery_workflow_execution_repository.py" = ["ARG002"] +"core/repositories/test_celery_workflow_node_execution_repository.py" = ["ARG002"] +"core/repositories/test_human_input_form_repository_impl.py" = ["ARG001", "ARG005"] +"core/repositories/test_human_input_repository.py" = ["ANN401", "ARG001", "ARG005", "TID251"] +"core/repositories/test_sqlalchemy_workflow_node_execution_repository.py" = ["ANN401", "ARG002", "ARG005", "TID251"] +"core/repositories/test_workflow_node_execution_truncation.py" = ["TID251"] +"core/schemas/test_resolver.py" = ["ARG005", "T201"] +"core/telemetry/test_facade.py" = ["ARG002", "ARG004"] +"core/telemetry/test_gateway_integration.py" = ["ARG002"] +"core/test_model_manager.py" = ["ARG001"] +"core/test_trigger_debug_event_selectors.py" = ["ARG002"] +"core/tools/test_base_tool.py" = ["ANN401", "ARG002", "TID251"] +"core/tools/test_builtin_tool_base.py" = ["ARG001", "ARG002", "TID251"] +"core/tools/test_builtin_tool_provider.py" = ["ARG001", "ARG005", "TID251"] +"core/tools/test_builtin_tools_extra.py" = ["ARG005"] +"core/tools/test_custom_tool.py" = ["ARG001", "ARG005", "TID251"] +"core/tools/test_dataset_retriever_tool.py" = ["ARG005"] +"core/tools/test_mcp_tool.py" = ["S110"] +"core/tools/test_tool_engine.py" = ["ANN401", "ARG002", "ARG005", "TID251"] +"core/tools/test_tool_file_manager.py" = ["ARG001"] +"core/tools/test_tool_label_manager.py" = ["TID251"] +"core/tools/test_tool_manager.py" = ["ANN401", "ARG001", "ARG005", "TID251"] +"core/tools/test_tool_provider_controller.py" = ["TID251"] +"core/tools/utils/test_configuration.py" = ["ARG001", "ARG002", "TID251"] +"core/tools/utils/test_encryption.py" = ["ANN401", "TID251"] +"core/tools/utils/test_message_transformer.py" = ["TID251"] +"core/tools/utils/test_misc_utils_extra.py" = ["ARG002"] +"core/tools/utils/test_model_invocation_utils.py" = ["ARG005", "TID251"] +"core/tools/utils/test_parser.py" = ["TID251"] +"core/tools/utils/test_web_reader_tool.py" = ["ARG001", "ARG002", "ARG005"] +"core/tools/workflow_as_tool/test_provider.py" = ["TID251"] +"core/tools/workflow_as_tool/test_tool.py" = ["ANN401", "ARG001", "ARG005", "TID251"] +"core/trigger/conftest.py" = ["ANN401", "TID251"] +"core/trigger/debug/test_debug_event_selectors.py" = ["ARG002", "TID251"] +"core/variables/test_segment_type_validation.py" = ["TID251"] +"core/workflow/context/test_execution_context.py" = ["ANN401", "ARG002", "S110", "TID251"] +"core/workflow/context/test_flask_app_context.py" = ["ARG002"] +"core/workflow/generator/test_runner.py" = ["ARG001", "ARG002", "TID251"] +"core/workflow/generator/test_runner_missing.py" = ["ARG003"] +"core/workflow/generator/test_tool_catalogue.py" = ["ARG002"] +"core/workflow/graph_engine/layers/test_observability.py" = ["ARG002"] +"core/workflow/graph_engine/test_mock_config.py" = ["TID251"] +"core/workflow/graph_engine/test_mock_factory.py" = ["TID251"] +"core/workflow/graph_engine/test_mock_nodes.py" = ["ANN401", "S110", "TID251"] +"core/workflow/graph_engine/test_parallel_human_input_join_resume.py" = ["ARG002", "TID251"] +"core/workflow/graph_engine/test_table_runner.py" = ["ARG001", "TID251"] +"core/workflow/nodes/agent_v2/test_agent_node.py" = ["ARG001", "ARG002", "ARG005"] +"core/workflow/nodes/agent_v2/test_ask_human_hitl.py" = ["ANN401", "TID251"] +"core/workflow/nodes/agent_v2/test_dify_tools_builder.py" = ["ANN401", "ARG001", "ARG002", "ARG005", "TID251"] +"core/workflow/nodes/agent_v2/test_output_adapter.py" = ["ARG005"] +"core/workflow/nodes/agent_v2/test_runtime_request_builder.py" = ["ARG002"] +"core/workflow/nodes/agent_v2/test_validators.py" = ["ARG001"] +"core/workflow/nodes/http_request/test_http_request_node.py" = ["ANN401", "ARG002", "TID251"] +"core/workflow/nodes/human_input/test_entities.py" = ["TID251"] +"core/workflow/nodes/human_input/test_human_input_form_filled_event.py" = ["TID251"] +"core/workflow/nodes/iteration/test_iteration_child_engine_errors.py" = ["ARG002", "TID251"] +"core/workflow/nodes/knowledge_index/test_knowledge_index_node.py" = ["ARG002"] +"core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py" = ["ARG002"] +"core/workflow/nodes/llm/test_node.py" = ["ARG002"] +"core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py" = ["TID251"] +"core/workflow/nodes/test_document_extractor_node.py" = ["ARG001"] +"core/workflow/nodes/tool/test_tool_node.py" = ["ANN401", "ARG002", "TID251"] +"core/workflow/nodes/webhook/test_webhook_file_conversion.py" = ["TID251"] +"core/workflow/nodes/webhook/test_webhook_node.py" = ["TID251"] +"core/workflow/test_form_input_serialization_compat.py" = ["ANN401", "TID251"] +"core/workflow/test_human_input_adapter.py" = ["ARG005"] +"core/workflow/test_node_factory.py" = ["ARG002"] +"core/workflow/test_workflow_entry.py" = ["ARG001"] +"enterprise/telemetry/test_enterprise_trace.py" = ["ARG002", "TID251"] +"enterprise/telemetry/test_exporter.py" = ["ARG001"] +"enterprise/telemetry/test_gateway.py" = ["ARG002"] +"events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py" = ["ARG005"] +"extensions/logstore/test_sql_escape.py" = ["ARG001", "ARG002"] +"extensions/otel/decorators/handlers/test_generate_handler.py" = ["ARG001", "ARG002"] +"extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py" = ["ARG001"] +"extensions/otel/decorators/test_base.py" = ["ARG002"] +"extensions/otel/decorators/test_handler.py" = ["ARG002"] +"extensions/otel/test_retrieval_tracing.py" = ["ARG001"] +"extensions/test_ext_request_logging.py" = ["ARG002"] +"extensions/test_redis.py" = ["ARG001"] +"factories/test_build_from_mapping.py" = ["ARG001"] +"factories/test_file_factory.py" = ["ARG001"] +"factories/test_variable_factory.py" = ["TID251"] +"fields/test_file_fields.py" = ["ARG005"] +"libs/_human_input/support.py" = ["TID251"] +"libs/broadcast_channel/redis/test_channel_unit_tests.py" = ["ARG002", "ARG005"] +"libs/broadcast_channel/redis/test_streams_channel_unit_tests.py" = ["ARG001", "ARG002", "TID251"] +"libs/test_cron_compatibility.py" = ["S110"] +"libs/test_email_i18n.py" = ["ANN401", "TID251"] +"libs/test_oauth_bearer_rate_limit_ordering.py" = ["ARG001"] +"libs/test_pyrefly_type_coverage.py" = ["TID251"] +"libs/test_schedule_utils_enhanced.py" = ["S110"] +"libs/test_sendgrid_client.py" = ["ARG001", "TID251"] +"libs/test_smtp_client.py" = ["TID251"] +"models/test_dataset_models.py" = ["ARG005"] +"models/test_plugin_entities.py" = ["TID251"] +"models/test_snippet.py" = ["ARG001"] +"oss/__mock/aliyun_oss.py" = ["ARG002"] +"oss/__mock/baidu_obs.py" = ["ARG002"] +"oss/__mock/base.py" = ["ARG002"] +"oss/__mock/tencent_cos.py" = ["ARG002"] +"oss/__mock/volcengine_tos.py" = ["ARG002"] +"oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py" = ["ARG002"] +"oss/baidu_obs/test_baidu_obs.py" = ["ARG002"] +"oss/opendal/test_opendal.py" = ["ARG002"] +"oss/tencent_cos/test_tencent_cos.py" = ["ARG002"] +"oss/volcengine_tos/test_volcengine_tos.py" = ["ARG002"] +"services/agent/test_agent_observability_service.py" = ["ARG002", "ARG005"] +"services/agent/test_agent_services.py" = ["ARG001", "ARG002", "ARG003", "ARG005"] +"services/agent/test_composer_candidates.py" = ["ARG005"] +"services/agent/test_prompt_mentions.py" = ["ARG005"] +"services/agent/test_skill_tool_inference_service.py" = ["ARG001", "ARG005"] +"services/auth/test_jina_auth_standalone_module.py" = ["TID251"] +"services/controller_api.py" = ["ARG002"] +"services/data_migration/test_import_service.py" = ["ARG002", "ARG005"] +"services/dataset_service_test_helpers.py" = ["TID251"] +"services/enterprise/test_account_deletion_sync.py" = ["ARG001"] +"services/enterprise/test_rbac_service.py" = ["ARG002"] +"services/enterprise/test_traceparent_propagation.py" = ["ARG002"] +"services/hit_service.py" = ["TID251"] +"services/plugin/test_plugin_parameter_service.py" = ["ARG002"] +"services/rag_pipeline/pipeline_template/test_built_in_retrieval.py" = ["ARG001"] +"services/rag_pipeline/test_rag_pipeline_dsl_service.py" = ["ARG001", "ARG005", "T201", "TID251"] +"services/rag_pipeline/test_rag_pipeline_service.py" = ["ARG001", "ARG005"] +"services/rag_pipeline/test_rag_pipeline_task_proxy.py" = ["ARG001", "ARG005"] +"services/rag_pipeline/test_rag_pipeline_transform_service.py" = ["ARG001"] +"services/recommend_app/test_remote_retrieval.py" = ["ARG002"] +"services/retention/workflow_run/test_archive_download_preparation.py" = ["ARG002"] +"services/retention/workflow_run/test_archive_log_service.py" = ["ARG001", "ARG002"] +"services/retention/workflow_run/test_bundle_archive_maintenance.py" = ["TID251"] +"services/retention/workflow_run/test_restore_archived_workflow_run.py" = ["ARG002"] +"services/test_account_service.py" = ["ARG001", "ARG002"] +"services/test_annotation_service.py" = ["ANN401", "TID251"] +"services/test_api_token_service.py" = ["ARG002"] +"services/test_app_generate_service.py" = ["ARG001", "ARG002", "ARG004"] +"services/test_app_generate_service_streaming_integration.py" = ["ARG002", "TID251"] +"services/test_archive_workflow_run_logs.py" = ["ARG002"] +"services/test_audio_service.py" = ["ARG002", "TID251"] +"services/test_batch_indexing_base.py" = ["ANN401", "TID251"] +"services/test_billing_service.py" = ["ARG001", "ARG002"] +"services/test_clear_free_plan_expired_workflow_run_logs.py" = ["ANN401", "ARG002", "ARG005", "TID251"] +"services/test_clear_free_plan_tenant_expired_logs.py" = ["ARG002", "ARG003"] +"services/test_dataset_service_document.py" = ["ARG002"] +"services/test_dataset_service_lock_not_owned.py" = ["ARG001", "ARG005"] +"services/test_dataset_service_segment.py" = ["ARG002"] +"services/test_datasource_provider_service.py" = ["ARG002"] +"services/test_external_dataset_service.py" = ["ARG002", "TID251"] +"services/test_feature_service_human_input_email_delivery.py" = ["ARG005"] +"services/test_feedback_service.py" = ["ARG002"] +"services/test_human_input_delivery_test_service.py" = ["ARG005"] +"services/test_knowledge_service.py" = ["TID251"] +"services/test_message_service.py" = ["ARG002"] +"services/test_messages_clean_service.py" = ["TID251"] +"services/test_model_load_balancing_service.py" = ["ANN401", "ARG001", "ARG005", "TID251"] +"services/test_model_provider_service.py" = ["ANN401", "TID251"] +"services/test_model_provider_service_sanitization.py" = ["ARG002", "ARG005"] +"services/test_oauth_server_service.py" = ["ARG002"] +"services/test_operation_service.py" = ["TID251"] +"services/test_rag_pipeline_task_proxy.py" = ["ARG002"] +"services/test_recommended_app_service.py" = ["ARG001"] +"services/test_schedule_service.py" = ["ANN401", "TID251"] +"services/test_snippet_service.py" = ["ARG001", "ARG002"] +"services/test_summary_index_service.py" = ["ARG001"] +"services/test_telemetry_service.py" = ["ARG001", "ARG005"] +"services/test_variable_truncator.py" = ["ARG002", "TID251"] +"services/test_variable_truncator_additional.py" = ["ANN401", "TID251"] +"services/test_vector_service.py" = ["ARG001", "TID251"] +"services/test_webhook_service_additional.py" = ["ANN401", "ARG002", "TID251"] +"services/test_website_service.py" = ["TID251"] +"services/test_workflow_comment_service.py" = ["ARG001", "ARG002"] +"services/test_workflow_run_service.py" = ["ANN401", "ARG002", "TID251"] +"services/test_workflow_service.py" = ["ANN401", "ARG002", "TID251"] +"services/tools/test_builtin_tools_manage_service.py" = ["ARG001", "ARG002"] +"services/tools/test_tools_manage_service.py" = ["ARG002"] +"services/workflow/test_inspector_events.py" = ["ANN401", "TID251"] +"services/workflow/test_node_output_inspector_service.py" = ["TID251"] +"services/workflow/test_workflow_converter_additional.py" = ["ANN401", "ARG001", "ARG005", "TID251"] +"services/workflow/test_workflow_event_snapshot_service.py" = ["ANN401", "ARG002", "ARG005", "TID251"] +"services/workflow/test_workflow_event_snapshot_service_additional.py" = ["ANN401", "ARG002", "ARG005", "TID251"] +"tasks/test_agent_backend_session_cleanup_task.py" = ["ARG005"] +"tasks/test_clean_dataset_task.py" = ["ARG002"] +"tasks/test_clean_document_task.py" = ["ARG002"] +"tasks/test_dataset_indexing_task.py" = ["ARG001", "ARG002"] +"tasks/test_document_indexing_sync_task.py" = ["ARG002"] +"tasks/test_duplicate_document_indexing_task.py" = ["ARG002"] +"tasks/test_human_input_timeout_tasks.py" = ["ARG001", "ARG002", "ARG005", "TID251"] +"tasks/test_initialize_created_app_rbac_access_task.py" = ["ARG005"] +"tasks/test_mail_send_task.py" = ["ARG002"] +"tasks/test_ops_trace_task.py" = ["ARG004"] +"tasks/test_process_tenant_plugin_autoupgrade_check_task.py" = ["ARG001"] +"tasks/test_remove_app_and_related_data_task.py" = ["ARG002"] +"tasks/test_trigger_processing_tasks.py" = ["ARG002"] +"tasks/test_workflow_execute_task.py" = ["ARG005"] +"test_app_factory.py" = ["ARG001"] +"test_pytest_dify.py" = ["ARG001"] +"tools/test_mcp_tool.py" = ["TID251"] + +[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"] +msg = "Use Pydantic payload/query models instead of reqparse." + +[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"] +msg = "Use Pydantic payload/query models instead of reqparse." + +[lint.flake8-tidy-imports.banned-api."typing.Any"] +msg = "Use object, Protocol, TypedDict, TypeVar, ParamSpec, or a localized cast instead." diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml new file mode 100644 index 00000000000..ad591ee5530 --- /dev/null +++ b/api/tests/unit_tests/pyrefly.toml @@ -0,0 +1,961 @@ +preset = "strict" +project-includes = ["."] +search-path = ["../.."] +python-platform = "linux" +python-version = "3.12.0" +infer-with-first-use = true +min-severity = "warn" + +# Existing strict-mode debt. Remove a file when bringing it under strict checking. +project-excludes = [ + "clients/agent_backend/test_cleanup_composition_compositor_integration.py", + "clients/agent_backend/test_client.py", + "clients/agent_backend/test_event_adapter.py", + "clients/agent_backend/test_fake_client.py", + "clients/agent_backend/test_request_builder.py", + "clients/agent_backend/test_session_cleanup.py", + "commands/test_archive_workflow_runs.py", + "commands/test_clean_expired_messages.py", + "commands/test_data_migration_commands.py", + "commands/test_data_migration_wizard.py", + "commands/test_fix_app_site_missing.py", + "commands/test_generate_swagger_markdown_docs.py", + "commands/test_generate_swagger_specs.py", + "commands/test_legacy_model_type_migration.py", + "commands/test_lint_response_contracts.py", + "commands/test_reset_encrypt_key_pair.py", + "commands/test_upgrade_db.py", + "configs/test_dify_config.py", + "configs/test_env_consistency.py", + "configs/test_nacos_http_client.py", + "conftest.py", + "controllers/common/test_agent_app_parameters.py", + "controllers/common/test_app_access.py", + "controllers/common/test_errors.py", + "controllers/common/test_fields.py", + "controllers/common/test_file_response.py", + "controllers/common/test_helpers.py", + "controllers/common/test_schema.py", + "controllers/common/test_session.py", + "controllers/console/agent/test_agent_controllers.py", + "controllers/console/app/test_agent_app_sandbox.py", + "controllers/console/app/test_agent_config_inspector.py", + "controllers/console/app/test_agent_drive_inspector.py", + "controllers/console/app/test_agent_manage_guard.py", + "controllers/console/app/test_agent_skills.py", + "controllers/console/app/test_annotation_api.py", + "controllers/console/app/test_annotation_security.py", + "controllers/console/app/test_app_apis.py", + "controllers/console/app/test_app_import_api.py", + "controllers/console/app/test_app_response_models.py", + "controllers/console/app/test_audio.py", + "controllers/console/app/test_conversation_api.py", + "controllers/console/app/test_conversation_variables_api.py", + "controllers/console/app/test_create_app_payload.py", + "controllers/console/app/test_description_validation.py", + "controllers/console/app/test_generator_api.py", + "controllers/console/app/test_generator_api_missing.py", + "controllers/console/app/test_mcp_server_response.py", + "controllers/console/app/test_message_api.py", + "controllers/console/app/test_model_config_api.py", + "controllers/console/app/test_ops_trace_api.py", + "controllers/console/app/test_statistic_api.py", + "controllers/console/app/test_workflow.py", + "controllers/console/app/test_workflow_app_log_api.py", + "controllers/console/app/test_workflow_comment_api.py", + "controllers/console/app/test_workflow_convert_api.py", + "controllers/console/app/test_workflow_node_output_inspector.py", + "controllers/console/app/test_workflow_pause_details_api.py", + "controllers/console/app/test_workflow_run_api.py", + "controllers/console/app/test_workflow_trigger_api.py", + "controllers/console/app/test_wraps.py", + "controllers/console/app/workflow_draft_variables_test.py", + "controllers/console/auth/test_account_activation.py", + "controllers/console/auth/test_authentication_security.py", + "controllers/console/auth/test_data_source_bearer_auth.py", + "controllers/console/auth/test_email_register.py", + "controllers/console/auth/test_email_register_language.py", + "controllers/console/auth/test_email_verification.py", + "controllers/console/auth/test_forgot_password.py", + "controllers/console/auth/test_login_logout.py", + "controllers/console/auth/test_oauth.py", + "controllers/console/auth/test_oauth_timezone.py", + "controllers/console/auth/test_password_reset.py", + "controllers/console/auth/test_token_refresh.py", + "controllers/console/billing/test_billing.py", + "controllers/console/datasets/rag_pipeline/test_datasource_auth.py", + "controllers/console/datasets/rag_pipeline/test_datasource_content_preview.py", + "controllers/console/datasets/rag_pipeline/test_rag_pipeline.py", + "controllers/console/datasets/rag_pipeline/test_rag_pipeline_draft_variable.py", + "controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py", + "controllers/console/datasets/test_data_source.py", + "controllers/console/datasets/test_datasets.py", + "controllers/console/datasets/test_datasets_document.py", + "controllers/console/datasets/test_datasets_document_download.py", + "controllers/console/datasets/test_datasets_segments.py", + "controllers/console/datasets/test_external.py", + "controllers/console/datasets/test_hit_testing.py", + "controllers/console/datasets/test_hit_testing_base.py", + "controllers/console/datasets/test_metadata.py", + "controllers/console/datasets/test_website.py", + "controllers/console/datasets/test_wraps.py", + "controllers/console/explore/test_audio.py", + "controllers/console/explore/test_banner.py", + "controllers/console/explore/test_completion.py", + "controllers/console/explore/test_installed_app.py", + "controllers/console/explore/test_message.py", + "controllers/console/explore/test_parameter.py", + "controllers/console/explore/test_recommended_app.py", + "controllers/console/explore/test_saved_message.py", + "controllers/console/explore/test_trial.py", + "controllers/console/explore/test_workflow.py", + "controllers/console/explore/test_wraps.py", + "controllers/console/snippets/test_snippet_workflow.py", + "controllers/console/snippets/test_snippet_workflow_draft_variable.py", + "controllers/console/tag/test_tags.py", + "controllers/console/test_document_detail_api_data_source_info.py", + "controllers/console/test_extension.py", + "controllers/console/test_fastopenapi_ping.py", + "controllers/console/test_fastopenapi_setup.py", + "controllers/console/test_fastopenapi_version.py", + "controllers/console/test_feature.py", + "controllers/console/test_files.py", + "controllers/console/test_files_security.py", + "controllers/console/test_human_input_form.py", + "controllers/console/test_knowledge_fs_proxy.py", + "controllers/console/test_remote_files.py", + "controllers/console/test_spec.py", + "controllers/console/test_version.py", + "controllers/console/test_workflow_run_archive.py", + "controllers/console/test_workspace_account.py", + "controllers/console/test_workspace_members.py", + "controllers/console/test_wraps.py", + "controllers/console/workspace/test_accounts.py", + "controllers/console/workspace/test_agent_providers.py", + "controllers/console/workspace/test_endpoint.py", + "controllers/console/workspace/test_load_balancing_config.py", + "controllers/console/workspace/test_members.py", + "controllers/console/workspace/test_model_providers.py", + "controllers/console/workspace/test_models.py", + "controllers/console/workspace/test_plugin.py", + "controllers/console/workspace/test_rbac.py", + "controllers/console/workspace/test_snippets.py", + "controllers/console/workspace/test_tool_providers.py", + "controllers/console/workspace/test_workspace.py", + "controllers/files/test_image_preview.py", + "controllers/files/test_tool_files.py", + "controllers/files/test_upload.py", + "controllers/inner_api/app/test_dsl.py", + "controllers/inner_api/plugin/test_agent_config.py", + "controllers/inner_api/plugin/test_agent_drive.py", + "controllers/inner_api/plugin/test_plugin.py", + "controllers/inner_api/plugin/test_plugin_wraps.py", + "controllers/inner_api/test_auth_wraps.py", + "controllers/inner_api/test_knowledge_retrieval.py", + "controllers/inner_api/test_mail.py", + "controllers/inner_api/test_runtime_credentials.py", + "controllers/inner_api/workspace/test_workspace.py", + "controllers/mcp/test_mcp.py", + "controllers/openapi/auth/test_composition.py", + "controllers/openapi/auth/test_conditions.py", + "controllers/openapi/auth/test_data.py", + "controllers/openapi/auth/test_flow.py", + "controllers/openapi/auth/test_pipeline.py", + "controllers/openapi/auth/test_prepare.py", + "controllers/openapi/auth/test_verify.py", + "controllers/openapi/conftest.py", + "controllers/openapi/test_account.py", + "controllers/openapi/test_app_describe_builder.py", + "controllers/openapi/test_app_list_query.py", + "controllers/openapi/test_app_payloads.py", + "controllers/openapi/test_app_run_dispatch.py", + "controllers/openapi/test_app_run_rate_limit.py", + "controllers/openapi/test_app_run_streaming.py", + "controllers/openapi/test_apps_permitted_external_query.py", + "controllers/openapi/test_audit_app_run.py", + "controllers/openapi/test_contract.py", + "controllers/openapi/test_cors.py", + "controllers/openapi/test_device_approve_deny.py", + "controllers/openapi/test_device_code.py", + "controllers/openapi/test_device_lookup.py", + "controllers/openapi/test_device_sso.py", + "controllers/openapi/test_device_token.py", + "controllers/openapi/test_error_contract.py", + "controllers/openapi/test_health.py", + "controllers/openapi/test_human_input_form.py", + "controllers/openapi/test_input_schema.py", + "controllers/openapi/test_meta_version.py", + "controllers/openapi/test_models.py", + "controllers/openapi/test_oauth_sso_claims.py", + "controllers/openapi/test_oauth_sso_csrf.py", + "controllers/openapi/test_oauth_sso_host_header.py", + "controllers/openapi/test_pagination_envelope.py", + "controllers/openapi/test_supported_app_type.py", + "controllers/openapi/test_version_gate.py", + "controllers/openapi/test_workflow_events_openapi.py", + "controllers/openapi/test_workspaces.py", + "controllers/openapi/test_workspaces_members.py", + "controllers/service_api/app/test_annotation.py", + "controllers/service_api/app/test_app.py", + "controllers/service_api/app/test_audio.py", + "controllers/service_api/app/test_chat_request_payload.py", + "controllers/service_api/app/test_completion.py", + "controllers/service_api/app/test_conversation.py", + "controllers/service_api/app/test_file.py", + "controllers/service_api/app/test_file_preview.py", + "controllers/service_api/app/test_hitl_service_api.py", + "controllers/service_api/app/test_human_input_form.py", + "controllers/service_api/app/test_message.py", + "controllers/service_api/app/test_workflow.py", + "controllers/service_api/app/test_workflow_events.py", + "controllers/service_api/conftest.py", + "controllers/service_api/dataset/rag_pipeline/test_rag_pipeline_workflow.py", + "controllers/service_api/dataset/test_dataset_segment.py", + "controllers/service_api/dataset/test_document.py", + "controllers/service_api/dataset/test_hit_testing.py", + "controllers/service_api/dataset/test_metadata.py", + "controllers/service_api/dataset/test_rag_pipeline_file_upload_serialization.py", + "controllers/service_api/dataset/test_rag_pipeline_route_registration.py", + "controllers/service_api/test_index.py", + "controllers/service_api/test_trace_session_id_parsing.py", + "controllers/service_api/test_wraps.py", + "controllers/test_compare_versions.py", + "controllers/test_conversation_rename_payload.py", + "controllers/test_swagger.py", + "controllers/trigger/test_trigger.py", + "controllers/trigger/test_webhook.py", + "controllers/web/conftest.py", + "controllers/web/test_app.py", + "controllers/web/test_completion.py", + "controllers/web/test_human_input_file_upload.py", + "controllers/web/test_human_input_form.py", + "controllers/web/test_message_endpoints.py", + "controllers/web/test_message_list.py", + "controllers/web/test_pydantic_models.py", + "controllers/web/test_web_forgot_password.py", + "controllers/web/test_web_login.py", + "controllers/web/test_web_passport.py", + "controllers/web/test_workflow.py", + "controllers/web/test_wraps.py", + "core/agent/conftest.py", + "core/agent/output_parser/test_cot_output_parser.py", + "core/agent/strategy/test_base.py", + "core/agent/strategy/test_plugin.py", + "core/agent/test_base_agent_runner.py", + "core/agent/test_cot_agent_runner.py", + "core/agent/test_cot_chat_agent_runner.py", + "core/agent/test_cot_completion_agent_runner.py", + "core/agent/test_fc_agent_runner.py", + "core/agent/test_plugin_entities.py", + "core/app/app_config/common/test_parameters_mapping.py", + "core/app/app_config/common/test_sensitive_word_avoidance_manager.py", + "core/app/app_config/easy_ui_based_app/test_agent_manager.py", + "core/app/app_config/easy_ui_based_app/test_dataset_manager.py", + "core/app/app_config/easy_ui_based_app/test_model_config_converter.py", + "core/app/app_config/easy_ui_based_app/test_model_config_manager.py", + "core/app/app_config/easy_ui_based_app/test_prompt_template_manager.py", + "core/app/app_config/easy_ui_based_app/test_variables_manager.py", + "core/app/app_config/features/file_upload/test_manager.py", + "core/app/app_config/features/test_additional_feature_managers.py", + "core/app/app_config/test_base_app_config_manager.py", + "core/app/app_config/test_entities.py", + "core/app/app_config/workflow_ui_based_app/test_workflow_ui_based_app_manager.py", + "core/app/apps/advanced_chat/test_app_config_manager.py", + "core/app/apps/advanced_chat/test_app_generator.py", + "core/app/apps/advanced_chat/test_app_runner_conversation_variables.py", + "core/app/apps/advanced_chat/test_app_runner_input_moderation.py", + "core/app/apps/advanced_chat/test_generate_response_converter.py", + "core/app/apps/advanced_chat/test_generate_task_pipeline.py", + "core/app/apps/advanced_chat/test_generate_task_pipeline_core.py", + "core/app/apps/agent_app/test_app_config_manager.py", + "core/app/apps/agent_app/test_app_generator.py", + "core/app/apps/agent_app/test_app_runner.py", + "core/app/apps/agent_app/test_input_guards.py", + "core/app/apps/agent_app/test_resolve_agent.py", + "core/app/apps/agent_app/test_runtime_request_builder.py", + "core/app/apps/agent_app/test_session_store.py", + "core/app/apps/agent_chat/test_agent_chat_app_config_manager.py", + "core/app/apps/agent_chat/test_agent_chat_app_generator.py", + "core/app/apps/agent_chat/test_agent_chat_app_runner.py", + "core/app/apps/agent_chat/test_agent_chat_generate_response_converter.py", + "core/app/apps/chat/test_app_config_manager.py", + "core/app/apps/chat/test_app_generator_and_runner.py", + "core/app/apps/chat/test_base_app_runner_multimodal.py", + "core/app/apps/chat/test_generate_response_converter.py", + "core/app/apps/common/test_graph_runtime_state_support.py", + "core/app/apps/common/test_workflow_response_converter.py", + "core/app/apps/common/test_workflow_response_converter_human_input.py", + "core/app/apps/common/test_workflow_response_converter_resumption.py", + "core/app/apps/common/test_workflow_response_converter_truncation.py", + "core/app/apps/completion/test_app_runner.py", + "core/app/apps/completion/test_completion_app_config_manager.py", + "core/app/apps/completion/test_completion_completion_app_generator.py", + "core/app/apps/completion/test_completion_generate_response_converter.py", + "core/app/apps/pipeline/test_pipeline_config_manager.py", + "core/app/apps/pipeline/test_pipeline_generate_response_converter.py", + "core/app/apps/pipeline/test_pipeline_generator.py", + "core/app/apps/pipeline/test_pipeline_queue_manager.py", + "core/app/apps/pipeline/test_pipeline_runner.py", + "core/app/apps/test_advanced_chat_app_generator.py", + "core/app/apps/test_base_app_generate_response_converter.py", + "core/app/apps/test_base_app_generator.py", + "core/app/apps/test_base_app_queue_manager.py", + "core/app/apps/test_base_app_runner.py", + "core/app/apps/test_exc.py", + "core/app/apps/test_message_based_app_generator.py", + "core/app/apps/test_message_based_app_queue_manager.py", + "core/app/apps/test_message_generator.py", + "core/app/apps/test_pause_resume.py", + "core/app/apps/test_streaming_utils.py", + "core/app/apps/test_trace_session_id_generate_extras.py", + "core/app/apps/test_workflow_app_generator.py", + "core/app/apps/test_workflow_app_runner_core.py", + "core/app/apps/test_workflow_app_runner_notifications.py", + "core/app/apps/test_workflow_app_runner_single_node.py", + "core/app/apps/test_workflow_pause_events.py", + "core/app/apps/workflow/test_active_workflow_tasks.py", + "core/app/apps/workflow/test_app_config_manager.py", + "core/app/apps/workflow/test_app_generator_extra.py", + "core/app/apps/workflow/test_app_queue_manager.py", + "core/app/apps/workflow/test_command_channels.py", + "core/app/apps/workflow/test_errors.py", + "core/app/apps/workflow/test_generate_response_converter.py", + "core/app/apps/workflow/test_generate_task_pipeline.py", + "core/app/apps/workflow/test_generate_task_pipeline_core.py", + "core/app/entities/test_app_invoke_entities.py", + "core/app/entities/test_queue_entities.py", + "core/app/entities/test_rag_pipeline_invoke_entities.py", + "core/app/entities/test_task_entities.py", + "core/app/features/rate_limiting/conftest.py", + "core/app/features/rate_limiting/test_rate_limit.py", + "core/app/features/test_annotation_reply.py", + "core/app/features/test_hosting_moderation.py", + "core/app/layers/test_conversation_variable_persist_layer.py", + "core/app/layers/test_pause_state_persist_layer.py", + "core/app/layers/test_suspend_layer.py", + "core/app/layers/test_timeslice_layer.py", + "core/app/layers/test_trigger_post_layer.py", + "core/app/task_pipeline/test_based_generate_task_pipeline.py", + "core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py", + "core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py", + "core/app/task_pipeline/test_exc.py", + "core/app/task_pipeline/test_message_cycle_manager_optimization.py", + "core/app/test_easy_ui_model_config_manager.py", + "core/app/test_invoke_from.py", + "core/app/test_llm_quota.py", + "core/app/workflow/layers/test_persistence.py", + "core/app/workflow/layers/test_persistence_inspector_publish.py", + "core/app/workflow/test_file_runtime.py", + "core/app/workflow/test_node_factory.py", + "core/app/workflow/test_observability_layer_extra.py", + "core/app/workflow/test_persistence_layer.py", + "core/base/test_app_generator_tts_publisher.py", + "core/callback_handler/test_agent_tool_callback_handler.py", + "core/callback_handler/test_index_tool_callback_handler.py", + "core/callback_handler/test_workflow_tool_callback_handler.py", + "core/datasource/__base/test_datasource_plugin.py", + "core/datasource/__base/test_datasource_provider.py", + "core/datasource/__base/test_datasource_runtime.py", + "core/datasource/entities/test_api_entities.py", + "core/datasource/entities/test_common_entities.py", + "core/datasource/entities/test_datasource_entities.py", + "core/datasource/local_file/test_local_file_plugin.py", + "core/datasource/local_file/test_local_file_provider.py", + "core/datasource/online_document/test_online_document_plugin.py", + "core/datasource/online_document/test_online_document_provider.py", + "core/datasource/online_drive/test_online_drive_plugin.py", + "core/datasource/online_drive/test_online_drive_provider.py", + "core/datasource/test_datasource_file_manager.py", + "core/datasource/test_datasource_manager.py", + "core/datasource/test_errors.py", + "core/datasource/test_file_upload.py", + "core/datasource/test_notion_provider.py", + "core/datasource/test_website_crawl.py", + "core/datasource/utils/test_message_transformer.py", + "core/datasource/website_crawl/test_website_crawl_plugin.py", + "core/datasource/website_crawl/test_website_crawl_provider.py", + "core/entities/test_entities_mcp_provider.py", + "core/entities/test_entities_provider_configuration.py", + "core/extension/test_api_based_extension_requestor.py", + "core/extension/test_extensible.py", + "core/extension/test_extension.py", + "core/external_data_tool/api/test_api.py", + "core/external_data_tool/test_base.py", + "core/external_data_tool/test_external_data_fetch.py", + "core/external_data_tool/test_factory.py", + "core/file/test_models.py", + "core/file/test_remote_fetcher.py", + "core/helper/code_executor/javascript/test_javascript_transformer.py", + "core/helper/code_executor/jinja2/test_jinja2_sandbox.py", + "core/helper/code_executor/python3/test_python3_transformer.py", + "core/helper/code_executor/test_code_executor.py", + "core/helper/code_executor/test_code_node_provider.py", + "core/helper/code_executor/test_template_transformer.py", + "core/helper/test_creators.py", + "core/helper/test_credential_utils.py", + "core/helper/test_csv_sanitizer.py", + "core/helper/test_encrypter.py", + "core/helper/test_ssrf_proxy.py", + "core/helper/test_trace_id_helper.py", + "core/llm_generator/output_parser/test_rule_config_generator.py", + "core/llm_generator/output_parser/test_structured_output.py", + "core/llm_generator/test_llm_generator.py", + "core/llm_generator/test_llm_generator_missing.py", + "core/logging/test_context.py", + "core/logging/test_filters.py", + "core/logging/test_structured_formatter.py", + "core/logging/test_trace_helpers.py", + "core/mcp/auth/test_auth_flow.py", + "core/mcp/client/test_session.py", + "core/mcp/client/test_sse.py", + "core/mcp/client/test_streamable_http.py", + "core/mcp/server/test_streamable_http.py", + "core/mcp/session/test_base_session.py", + "core/mcp/session/test_client_session.py", + "core/mcp/test_auth_client_inheritance.py", + "core/mcp/test_entities.py", + "core/mcp/test_error.py", + "core/mcp/test_mcp_client.py", + "core/mcp/test_types.py", + "core/mcp/test_utils.py", + "core/memory/test_token_buffer_memory.py", + "core/model_runtime/test_model_provider_factory.py", + "core/moderation/api/test_api.py", + "core/moderation/test_content_moderation.py", + "core/moderation/test_input_moderation.py", + "core/moderation/test_output_moderation.py", + "core/moderation/test_sensitive_word_filter.py", + "core/ops/test_base_trace_instance.py", + "core/ops/test_config_entity.py", + "core/ops/test_lookup_helpers.py", + "core/ops/test_ops_trace_manager.py", + "core/ops/test_trace_queue_manager.py", + "core/ops/test_trace_session_metadata.py", + "core/ops/test_utils.py", + "core/plugin/impl/test_agent_client.py", + "core/plugin/impl/test_asset_manager.py", + "core/plugin/impl/test_base_client_impl.py", + "core/plugin/impl/test_datasource_manager.py", + "core/plugin/impl/test_debugging_client.py", + "core/plugin/impl/test_endpoint_client_impl.py", + "core/plugin/impl/test_exc_impl.py", + "core/plugin/impl/test_model_client.py", + "core/plugin/impl/test_model_runtime_factory.py", + "core/plugin/impl/test_oauth_handler.py", + "core/plugin/impl/test_tool_manager.py", + "core/plugin/impl/test_trigger_client.py", + "core/plugin/test_backwards_invocation_app.py", + "core/plugin/test_backwards_invocation_model.py", + "core/plugin/test_endpoint_client.py", + "core/plugin/test_model_runtime_adapter.py", + "core/plugin/test_plugin_entities.py", + "core/plugin/test_plugin_manager.py", + "core/plugin/test_plugin_runtime.py", + "core/plugin/utils/test_chunk_merger.py", + "core/plugin/utils/test_http_parser.py", + "core/prompt/test_advanced_prompt_transform.py", + "core/prompt/test_agent_history_prompt_transform.py", + "core/prompt/test_extract_thread_messages.py", + "core/prompt/test_prompt_message.py", + "core/prompt/test_prompt_transform.py", + "core/prompt/test_simple_prompt_transform.py", + "core/rag/cleaner/test_clean_processor.py", + "core/rag/data_post_processor/test_data_post_processor.py", + "core/rag/datasource/keyword/jieba/test_jieba.py", + "core/rag/datasource/keyword/jieba/test_jieba_keyword_table_handler.py", + "core/rag/datasource/keyword/jieba/test_stopwords.py", + "core/rag/datasource/keyword/test_keyword_base.py", + "core/rag/datasource/keyword/test_keyword_factory.py", + "core/rag/datasource/test_datasource_retrieval.py", + "core/rag/datasource/vdb/test_field.py", + "core/rag/datasource/vdb/test_vector_base.py", + "core/rag/datasource/vdb/test_vector_factory.py", + "core/rag/docstore/test_dataset_docstore.py", + "core/rag/embedding/test_cached_embedding.py", + "core/rag/embedding/test_embedding_base.py", + "core/rag/embedding/test_embedding_service.py", + "core/rag/extractor/blob/test_blob.py", + "core/rag/extractor/firecrawl/test_firecrawl.py", + "core/rag/extractor/test_csv_extractor.py", + "core/rag/extractor/test_excel_extractor.py", + "core/rag/extractor/test_extract_processor.py", + "core/rag/extractor/test_extractor_base.py", + "core/rag/extractor/test_helpers.py", + "core/rag/extractor/test_html_extractor.py", + "core/rag/extractor/test_jina_reader_extractor.py", + "core/rag/extractor/test_markdown_extractor.py", + "core/rag/extractor/test_notion_extractor.py", + "core/rag/extractor/test_pdf_extractor.py", + "core/rag/extractor/test_text_extractor.py", + "core/rag/extractor/test_word_extractor.py", + "core/rag/extractor/unstructured/test_unstructured_extractors.py", + "core/rag/extractor/watercrawl/test_watercrawl.py", + "core/rag/indexing/processor/test_paragraph_index_processor.py", + "core/rag/indexing/processor/test_qa_index_processor.py", + "core/rag/indexing/test_index_processor.py", + "core/rag/indexing/test_index_processor_base.py", + "core/rag/indexing/test_indexing_runner.py", + "core/rag/pipeline/test_queue.py", + "core/rag/rerank/test_reranker.py", + "core/rag/retrieval/test_dataset_retrieval.py", + "core/rag/retrieval/test_dataset_retrieval_methods.py", + "core/rag/retrieval/test_multi_dataset_function_call_router.py", + "core/rag/retrieval/test_multi_dataset_react_route.py", + "core/rag/splitter/test_text_splitter.py", + "core/repositories/test_celery_workflow_execution_repository.py", + "core/repositories/test_celery_workflow_node_execution_repository.py", + "core/repositories/test_factory.py", + "core/repositories/test_human_input_form_repository_impl.py", + "core/repositories/test_human_input_repository.py", + "core/repositories/test_sqlalchemy_workflow_execution_repository.py", + "core/repositories/test_sqlalchemy_workflow_node_execution_repository.py", + "core/repositories/test_workflow_node_execution_conflict_handling.py", + "core/repositories/test_workflow_node_execution_truncation.py", + "core/schemas/test_registry.py", + "core/schemas/test_resolver.py", + "core/schemas/test_schema_manager.py", + "core/telemetry/test_facade.py", + "core/telemetry/test_gateway_integration.py", + "core/test_file.py", + "core/test_model_manager.py", + "core/test_provider_configuration.py", + "core/test_provider_manager.py", + "core/test_trigger_debug_event_selectors.py", + "core/tools/entities/test_api_entities.py", + "core/tools/test_base_tool.py", + "core/tools/test_builtin_tool_base.py", + "core/tools/test_builtin_tool_provider.py", + "core/tools/test_builtin_tools_extra.py", + "core/tools/test_custom_tool.py", + "core/tools/test_custom_tool_provider.py", + "core/tools/test_dataset_retriever_tool.py", + "core/tools/test_mcp_tool.py", + "core/tools/test_mcp_tool_provider.py", + "core/tools/test_plugin_tool.py", + "core/tools/test_plugin_tool_provider.py", + "core/tools/test_tool_engine.py", + "core/tools/test_tool_entities.py", + "core/tools/test_tool_file_manager.py", + "core/tools/test_tool_label_manager.py", + "core/tools/test_tool_manager.py", + "core/tools/test_tool_parameter_type.py", + "core/tools/test_tool_provider_controller.py", + "core/tools/utils/test_configuration.py", + "core/tools/utils/test_encryption.py", + "core/tools/utils/test_message_transformer.py", + "core/tools/utils/test_misc_utils_extra.py", + "core/tools/utils/test_model_invocation_utils.py", + "core/tools/utils/test_parser.py", + "core/tools/utils/test_system_oauth_encryption.py", + "core/tools/utils/test_tool_engine_serialization.py", + "core/tools/utils/test_web_reader_tool.py", + "core/tools/utils/test_workflow_configuration_sync.py", + "core/tools/workflow_as_tool/test_provider.py", + "core/tools/workflow_as_tool/test_tool.py", + "core/trigger/conftest.py", + "core/trigger/debug/test_debug_event_bus.py", + "core/trigger/debug/test_debug_event_selectors.py", + "core/trigger/test_provider.py", + "core/trigger/test_trigger_manager.py", + "core/trigger/utils/test_utils_encryption.py", + "core/trigger/utils/test_utils_endpoint.py", + "core/trigger/utils/test_utils_locks.py", + "core/variables/test_segment.py", + "core/variables/test_segment_type.py", + "core/variables/test_segment_type_validation.py", + "core/variables/test_variables.py", + "core/workflow/context/test_execution_context.py", + "core/workflow/context/test_flask_app_context.py", + "core/workflow/entities/test_private_workflow_pause.py", + "core/workflow/generator/test_prompts.py", + "core/workflow/generator/test_runner.py", + "core/workflow/generator/test_runner_missing.py", + "core/workflow/generator/test_tool_catalogue.py", + "core/workflow/graph_engine/layers/conftest.py", + "core/workflow/graph_engine/layers/test_llm_quota.py", + "core/workflow/graph_engine/layers/test_observability.py", + "core/workflow/graph_engine/test_mock_config.py", + "core/workflow/graph_engine/test_mock_factory.py", + "core/workflow/graph_engine/test_mock_nodes.py", + "core/workflow/graph_engine/test_parallel_human_input_join_resume.py", + "core/workflow/graph_engine/test_table_runner.py", + "core/workflow/graph_engine/test_tool_in_chatflow.py", + "core/workflow/nodes/agent/test_message_transformer.py", + "core/workflow/nodes/agent/test_runtime_support.py", + "core/workflow/nodes/agent_v2/test_agent_node.py", + "core/workflow/nodes/agent_v2/test_binding_resolver.py", + "core/workflow/nodes/agent_v2/test_dify_tools_builder.py", + "core/workflow/nodes/agent_v2/test_file_tenant_validator.py", + "core/workflow/nodes/agent_v2/test_output_adapter.py", + "core/workflow/nodes/agent_v2/test_output_failure_orchestrator.py", + "core/workflow/nodes/agent_v2/test_output_file_rebacker.py", + "core/workflow/nodes/agent_v2/test_output_type_checker.py", + "core/workflow/nodes/agent_v2/test_runtime_request_builder.py", + "core/workflow/nodes/agent_v2/test_session_cleanup_layer.py", + "core/workflow/nodes/agent_v2/test_session_store.py", + "core/workflow/nodes/agent_v2/test_validators.py", + "core/workflow/nodes/answer/test_answer.py", + "core/workflow/nodes/base/test_base_node.py", + "core/workflow/nodes/base/test_get_node_type_classes_mapping.py", + "core/workflow/nodes/code/code_node_spec.py", + "core/workflow/nodes/datasource/test_datasource_node.py", + "core/workflow/nodes/http_request/test_http_request_executor.py", + "core/workflow/nodes/http_request/test_http_request_node.py", + "core/workflow/nodes/human_input/test_dify_owned_contracts.py", + "core/workflow/nodes/human_input/test_email_delivery_config.py", + "core/workflow/nodes/human_input/test_entities.py", + "core/workflow/nodes/human_input/test_human_input_form_filled_event.py", + "core/workflow/nodes/iteration/test_iteration_child_engine_errors.py", + "core/workflow/nodes/knowledge_index/test_knowledge_index_node.py", + "core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py", + "core/workflow/nodes/list_operator/node_spec.py", + "core/workflow/nodes/llm/test_llm_utils.py", + "core/workflow/nodes/llm/test_node.py", + "core/workflow/nodes/parameter_extractor/test_parameter_extractor_node.py", + "core/workflow/nodes/template_transform/template_transform_node_spec.py", + "core/workflow/nodes/template_transform/test_template_transform_node.py", + "core/workflow/nodes/test_base_node.py", + "core/workflow/nodes/test_document_extractor_node.py", + "core/workflow/nodes/test_if_else.py", + "core/workflow/nodes/test_list_operator.py", + "core/workflow/nodes/test_start_node_json_object.py", + "core/workflow/nodes/tool/test_tool_node.py", + "core/workflow/nodes/tool/test_tool_node_runtime.py", + "core/workflow/nodes/webhook/test_entities.py", + "core/workflow/nodes/webhook/test_exceptions.py", + "core/workflow/nodes/webhook/test_webhook_file_conversion.py", + "core/workflow/nodes/webhook/test_webhook_node.py", + "core/workflow/test_enrich_pause_reasons.py", + "core/workflow/test_form_input_serialization_compat.py", + "core/workflow/test_graph_topology.py", + "core/workflow/test_human_input_adapter.py", + "core/workflow/test_human_input_callback.py", + "core/workflow/test_human_input_forms.py", + "core/workflow/test_node_factory.py", + "core/workflow/test_node_mapping_bootstrap.py", + "core/workflow/test_node_runtime.py", + "core/workflow/test_system_variable.py", + "core/workflow/test_variable_pool.py", + "core/workflow/test_workflow_entry.py", + "core/workflow/test_workflow_entry_helpers.py", + "core/workflow/test_workflow_entry_redis_channel.py", + "dev/test_generate_knowledge_fs_contract.py", + "enterprise/telemetry/test_contracts.py", + "enterprise/telemetry/test_draft_trace.py", + "enterprise/telemetry/test_enterprise_trace.py", + "enterprise/telemetry/test_event_handlers.py", + "enterprise/telemetry/test_exporter.py", + "enterprise/telemetry/test_gateway.py", + "enterprise/telemetry/test_metric_handler.py", + "enums/test_quota_type.py", + "events/event_handlers/test_clean_when_document_deleted.py", + "events/event_handlers/test_delete_tool_parameters_cache_when_sync_draft_workflow.py", + "events/test_app_event_signals.py", + "events/test_events_package_compat.py", + "events/test_update_provider_when_message_created.py", + "extensions/logstore/repositories/test_logstore_api_workflow_node_execution_repository.py", + "extensions/logstore/test_sql_escape.py", + "extensions/otel/conftest.py", + "extensions/otel/decorators/handlers/test_generate_handler.py", + "extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py", + "extensions/otel/decorators/test_base.py", + "extensions/otel/decorators/test_handler.py", + "extensions/otel/test_celery_sqlcommenter.py", + "extensions/otel/test_context.py", + "extensions/otel/test_retrieval_tracing.py", + "extensions/otel/test_runtime.py", + "extensions/storage/test_supabase_storage.py", + "extensions/test_celery_ssl.py", + "extensions/test_ext_blueprints_openapi.py", + "extensions/test_ext_login.py", + "extensions/test_ext_request_logging.py", + "extensions/test_ext_socketio.py", + "extensions/test_pubsub_channel.py", + "extensions/test_redis.py", + "extensions/test_set_secretkey.py", + "extensions/test_workflow_warm_shutdown.py", + "factories/test_build_from_mapping.py", + "factories/test_file_factory.py", + "factories/test_file_validation.py", + "factories/test_variable_factory.py", + "fields/test_dataset_fields.py", + "fields/test_file_fields.py", + "fields/test_message_fields.py", + "libs/_human_input/support.py", + "libs/_human_input/test_form_service.py", + "libs/_human_input/test_models.py", + "libs/broadcast_channel/redis/test_channel_unit_tests.py", + "libs/broadcast_channel/redis/test_streams_channel_unit_tests.py", + "libs/test_api_token_cache.py", + "libs/test_archive_storage.py", + "libs/test_cron_compatibility.py", + "libs/test_custom_inputs.py", + "libs/test_datetime_utils.py", + "libs/test_email.py", + "libs/test_email_i18n.py", + "libs/test_encryption.py", + "libs/test_external_api.py", + "libs/test_file_utils.py", + "libs/test_flask_utils.py", + "libs/test_helper.py", + "libs/test_json_in_md_parser.py", + "libs/test_jwt_imports.py", + "libs/test_login.py", + "libs/test_oauth_base.py", + "libs/test_oauth_bearer.py", + "libs/test_oauth_bearer_layer0_cache.py", + "libs/test_oauth_bearer_rate_limit_ordering.py", + "libs/test_oauth_bearer_require_scope.py", + "libs/test_oauth_clients.py", + "libs/test_orjson.py", + "libs/test_pagination.py", + "libs/test_pandas.py", + "libs/test_passport.py", + "libs/test_password.py", + "libs/test_rate_limit_bearer.py", + "libs/test_rate_limiter.py", + "libs/test_rsa.py", + "libs/test_schedule_utils_enhanced.py", + "libs/test_sendgrid_client.py", + "libs/test_smtp_client.py", + "libs/test_time_parser.py", + "libs/test_token.py", + "libs/test_token_manager.py", + "libs/test_uuid_utils.py", + "libs/test_workspace_member_helper.py", + "libs/test_workspace_permission.py", + "libs/test_yarl.py", + "migrations/test_agent_drive_skill_metadata_refactor.py", + "migrations/test_uuidv7_pg18_migration.py", + "models/test_account_models.py", + "models/test_agent.py", + "models/test_app_models.py", + "models/test_base.py", + "models/test_conversation_variable.py", + "models/test_dataset_models.py", + "models/test_end_user_type.py", + "models/test_enums_creator_user_role.py", + "models/test_model.py", + "models/test_plugin_entities.py", + "models/test_provider_models.py", + "models/test_tool_models.py", + "models/test_types.py", + "models/test_workflow.py", + "models/test_workflow_models.py", + "models/test_workflow_node_execution_offload.py", + "oss/__mock/aliyun_oss.py", + "oss/__mock/baidu_obs.py", + "oss/__mock/base.py", + "oss/__mock/local.py", + "oss/__mock/tencent_cos.py", + "oss/__mock/volcengine_tos.py", + "oss/aliyun_oss/aliyun_oss/test_aliyun_oss.py", + "oss/baidu_obs/test_baidu_obs.py", + "oss/opendal/test_opendal.py", + "oss/tencent_cos/test_tencent_cos.py", + "oss/volcengine_tos/test_volcengine_tos.py", + "repositories/test_sqlalchemy_api_workflow_run_repository.py", + "services/agent/test_agent_composer_entities.py", + "services/agent/test_agent_dsl_service.py", + "services/agent/test_agent_observability_service.py", + "services/agent/test_agent_services.py", + "services/agent/test_composer_candidates.py", + "services/agent/test_composer_mention_validation.py", + "services/agent/test_prompt_mentions.py", + "services/agent/test_skill_package_service.py", + "services/agent/test_skill_standardize_service.py", + "services/agent/test_skill_tool_inference_service.py", + "services/agent/test_workflow_publish_service.py", + "services/auth/test_api_key_auth_base.py", + "services/auth/test_api_key_auth_factory.py", + "services/auth/test_api_key_auth_service.py", + "services/auth/test_auth_type.py", + "services/auth/test_firecrawl_auth.py", + "services/auth/test_jina_auth.py", + "services/auth/test_jina_auth_standalone_module.py", + "services/auth/test_watercrawl_auth.py", + "services/controller_api.py", + "services/data_migration/test_dependency_discovery_service.py", + "services/data_migration/test_entities.py", + "services/data_migration/test_export_service.py", + "services/data_migration/test_import_service.py", + "services/data_migration/test_package_service.py", + "services/data_migration/test_report_service.py", + "services/dataset_service_test_helpers.py", + "services/document_service_validation.py", + "services/enterprise/test_account_deletion_sync.py", + "services/enterprise/test_app_permitted_service.py", + "services/enterprise/test_enterprise_service.py", + "services/enterprise/test_plugin_manager_service.py", + "services/enterprise/test_rbac_service.py", + "services/enterprise/test_traceparent_propagation.py", + "services/hit_service.py", + "services/openapi/test_mint_policy.py", + "services/plugin/conftest.py", + "services/plugin/test_dependencies_analysis.py", + "services/plugin/test_endpoint_service.py", + "services/plugin/test_oauth_service.py", + "services/plugin/test_plugin_migration.py", + "services/plugin/test_plugin_parameter_service.py", + "services/plugin/test_plugin_service.py", + "services/plugin/test_plugin_service_installation.py", + "services/rag_pipeline/pipeline_template/test_built_in_retrieval.py", + "services/rag_pipeline/pipeline_template/test_pipeline_template_base.py", + "services/rag_pipeline/test_pipeline_generate_service.py", + "services/rag_pipeline/test_rag_pipeline_dsl_service.py", + "services/rag_pipeline/test_rag_pipeline_service.py", + "services/rag_pipeline/test_rag_pipeline_task_proxy.py", + "services/rag_pipeline/test_rag_pipeline_transform_service.py", + "services/recommend_app/test_buildin_retrieval.py", + "services/recommend_app/test_category_order.py", + "services/recommend_app/test_recommend_app_factory.py", + "services/recommend_app/test_recommend_app_type.py", + "services/recommend_app/test_remote_retrieval.py", + "services/retention/test_messages_clean_policy.py", + "services/retention/workflow_run/test_archive_download_preparation.py", + "services/retention/workflow_run/test_archive_download_task_cache.py", + "services/retention/workflow_run/test_archive_log_service.py", + "services/retention/workflow_run/test_bundle_archive_maintenance.py", + "services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py", + "services/retention/workflow_run/test_restore_archived_workflow_run.py", + "services/test_account_service.py", + "services/test_agent_app_feature_service.py", + "services/test_agent_app_sandbox_service.py", + "services/test_agent_config_service.py", + "services/test_agent_drive_service.py", + "services/test_agent_file_request_service.py", + "services/test_annotation_service.py", + "services/test_api_token_service.py", + "services/test_app_dsl_service.py", + "services/test_app_generate_service.py", + "services/test_app_generate_service_streaming_integration.py", + "services/test_app_model_config_service.py", + "services/test_app_service.py", + "services/test_app_task_service.py", + "services/test_archive_workflow_run_logs.py", + "services/test_async_workflow_service.py", + "services/test_audio_service.py", + "services/test_billing_service.py", + "services/test_clear_free_plan_expired_workflow_run_logs.py", + "services/test_clear_free_plan_tenant_expired_logs.py", + "services/test_code_based_extension_service.py", + "services/test_conversation_service.py", + "services/test_credential_permission_service.py", + "services/test_credit_pool_service.py", + "services/test_dataset_service_dataset.py", + "services/test_dataset_service_document.py", + "services/test_dataset_service_lock_not_owned.py", + "services/test_dataset_service_segment.py", + "services/test_datasource_provider_service.py", + "services/test_document_indexing_task_proxy.py", + "services/test_duplicate_document_indexing_task_proxy.py", + "services/test_export_app_messages.py", + "services/test_external_dataset_service.py", + "services/test_feature_service_app_dsl_version.py", + "services/test_feature_service_enable_app_deploy.py", + "services/test_feature_service_human_input_email_delivery.py", + "services/test_feature_service_learn_app.py", + "services/test_feature_service_licensed_seats.py", + "services/test_feature_service_trial_models.py", + "services/test_feature_service_vector_space.py", + "services/test_feature_service_webapp_public_access.py", + "services/test_feedback_service.py", + "services/test_file_service.py", + "services/test_human_input_delivery_test_service.py", + "services/test_human_input_file_upload_service.py", + "services/test_human_input_service.py", + "services/test_knowledge_retrieval_inner_service.py", + "services/test_knowledge_service.py", + "services/test_message_service.py", + "services/test_messages_clean_service.py", + "services/test_metadata_nullable_bug.py", + "services/test_metadata_service_session_boundary.py", + "services/test_model_load_balancing_service.py", + "services/test_model_provider_service.py", + "services/test_model_provider_service_sanitization.py", + "services/test_oauth_device_flow.py", + "services/test_oauth_server_service.py", + "services/test_operation_service.py", + "services/test_rag_pipeline_task_proxy.py", + "services/test_schedule_service.py", + "services/test_snippet_dsl_service.py", + "services/test_snippet_generate_service.py", + "services/test_snippet_service.py", + "services/test_step_by_step_tour_service.py", + "services/test_summary_index_service.py", + "services/test_telemetry_service.py", + "services/test_trigger_provider_service.py", + "services/test_variable_truncator.py", + "services/test_vector_service.py", + "services/test_webhook_service.py", + "services/test_webhook_service_additional.py", + "services/test_website_service.py", + "services/test_workflow_app_service_metadata.py", + "services/test_workflow_collaboration_service.py", + "services/test_workflow_comment_service.py", + "services/test_workflow_generator_service.py", + "services/test_workflow_node_execution_trace_service.py", + "services/test_workflow_run_service.py", + "services/test_workflow_run_service_pause.py", + "services/test_workflow_service.py", + "services/tools/test_api_tools_manage_service.py", + "services/tools/test_builtin_tools_manage_service.py", + "services/tools/test_mcp_tools_transform.py", + "services/tools/test_tool_labels_service.py", + "services/tools/test_tools_manage_service.py", + "services/tools/test_tools_transform_service.py", + "services/workflow/test_draft_var_loader_simple.py", + "services/workflow/test_inspector_events.py", + "services/workflow/test_node_output_inspector_service.py", + "services/workflow/test_queue_dispatcher.py", + "services/workflow/test_scheduler.py", + "services/workflow/test_workflow_converter_additional.py", + "services/workflow/test_workflow_draft_variable_service.py", + "services/workflow/test_workflow_event_snapshot_service.py", + "services/workflow/test_workflow_event_snapshot_service_additional.py", + "services/workflow/test_workflow_human_input_delivery.py", + "services/workflow/test_workflow_restore.py", + "tasks/test_agent_backend_session_cleanup_task.py", + "tasks/test_async_workflow_tasks.py", + "tasks/test_batch_clean_document_task.py", + "tasks/test_clean_dataset_task.py", + "tasks/test_clean_document_task.py", + "tasks/test_community_telemetry_task.py", + "tasks/test_dataset_indexing_task.py", + "tasks/test_document_indexing_sync_task.py", + "tasks/test_document_indexing_update_task.py", + "tasks/test_duplicate_document_indexing_task.py", + "tasks/test_enable_segment_index_tasks.py", + "tasks/test_enterprise_telemetry_task.py", + "tasks/test_human_input_timeout_tasks.py", + "tasks/test_initialize_created_app_rbac_access_task.py", + "tasks/test_install_default_plugins_task.py", + "tasks/test_mail_human_input_delivery_task.py", + "tasks/test_mail_send_task.py", + "tasks/test_ops_trace_task.py", + "tasks/test_process_tenant_plugin_autoupgrade_check_task.py", + "tasks/test_refresh_billing_vector_space_task.py", + "tasks/test_remove_app_and_related_data_task.py", + "tasks/test_resume_agent_app_task.py", + "tasks/test_summary_queue_isolation.py", + "tasks/test_trigger_processing_tasks.py", + "tasks/test_workflow_execute_task.py", + "test_app_factory.py", + "test_makefile_backend_tests.py", + "test_pytest_dify.py", + "tools/test_api_tool.py", + "tools/test_mcp_tool.py", + "utils/encryption/test_system_encryption.py", + "utils/http_parser/test_oauth_convert_request_to_raw_data.py", + "utils/position_helper/test_position_helper.py", + "utils/structured_output_parser/test_structured_output_parser.py", + "utils/test_text_processing.py", + "utils/yaml/test_yaml_utils.py", +] + +[errors] +missing-override-decorator = "error" +redundant-cast = true +unannotated-return = true +unnecessary-type-conversion = true +unused-ignore = true diff --git a/dev/pyrefly-check-local b/dev/pyrefly-check-local index 4b975792701..b14dfba996f 100755 --- a/dev/pyrefly-check-local +++ b/dev/pyrefly-check-local @@ -7,6 +7,7 @@ REPO_ROOT="$SCRIPT_DIR/.." cd "$REPO_ROOT" EXCLUDES_FILE="api/pyrefly-local-excludes.txt" +UNIT_TESTS_CONFIG="tests/unit_tests/pyrefly.toml" TEST_CONTAINERS_DIR="tests/test_containers_integration_tests" TEST_CONTAINERS_CONFIG="$TEST_CONTAINERS_DIR/pyrefly.toml" @@ -74,6 +75,19 @@ fi run_pyrefly "${pyrefly_command[@]}" || status=$? if (( ${#target_paths[@]} == 0 )); then + unit_tests_args=( + "--summary=none" + "--use-ignore-files=false" + "--config=$UNIT_TESTS_CONFIG" + ) + if [[ "${PYREFLY_OUTPUT_FORMAT:-}" == "github" ]]; then + unit_tests_args+=("--output-format=github") + fi + run_pyrefly \ + uv run --directory api --dev pyrefly check \ + "${unit_tests_args[@]}" \ + || status=$? + test_containers_args=( "--summary=none" "--use-ignore-files=false" From 798e5ed7a736dbb88f7e5194dc3bd5d4fe3d6eb9 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:32:03 +0800 Subject: [PATCH 102/531] refactor(web): derive workspace billing from current workspace (#39674) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/workspace-card.spec.tsx | 42 +++++-------------- .../main-nav/components/workspace-card.tsx | 17 ++++---- 2 files changed, 18 insertions(+), 41 deletions(-) diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index dfc1b35d04b..fb9205b2248 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -1,6 +1,7 @@ +import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' -import type { ICurrentWorkspace, IWorkspace } from '@/models/common' +import type { IWorkspace } from '@/models/common' import { fireEvent, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Plan } from '@/app/components/billing/type' @@ -95,14 +96,13 @@ vi.mock('@/service/client', async (importOriginal) => { } }) -const currentWorkspaceValue: ICurrentWorkspace = { +const currentWorkspaceValue: PostWorkspacesCurrentResponse = { id: 'workspace-1', name: 'Solar Studio', plan: Plan.sandbox, status: 'normal', created_at: 0, role: 'owner', - providers: [], trial_credits: 10000, trial_credits_used: 2500, trial_credits_exhausted_at: 0, @@ -111,11 +111,11 @@ const currentWorkspaceValue: ICurrentWorkspace = { const mockSetShowPricingModal = vi.fn() const mockSetShowAccountSettingModal = vi.fn() -let mockCurrentWorkspace: ICurrentWorkspace | undefined = currentWorkspaceValue +let mockCurrentWorkspace: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue let mockWorkspaces: IWorkspace[] = [] const mockCurrentWorkspaceQuery = ( - data: ICurrentWorkspace | undefined = currentWorkspaceValue, + data: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue, isPending = false, ) => { mockCurrentWorkspace = isPending ? undefined : data @@ -284,13 +284,12 @@ describe('WorkspaceCard', () => { plan: Plan.team, }) vi.mocked(useProviderContext).mockReturnValue({ - enableBilling: true, + enableBilling: false, isEducationAccount: false, isEducationWorkspace: false, isFetchedPlan: true, - plan: { type: Plan.team }, + plan: { type: Plan.sandbox }, } as ProviderContextState) - renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) expect(screen.getByText(Plan.team)).toBeInTheDocument() @@ -304,14 +303,6 @@ describe('WorkspaceCard', () => { ...currentWorkspaceValue, plan: Plan.team, }) - vi.mocked(useProviderContext).mockReturnValue({ - enableBilling: true, - isEducationAccount: false, - isEducationWorkspace: false, - isFetchedPlan: true, - plan: { type: Plan.team }, - } as ProviderContextState) - renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) expect(screen.getByText(Plan.team)).toBeInTheDocument() @@ -322,14 +313,6 @@ describe('WorkspaceCard', () => { ...currentWorkspaceValue, plan: '', }) - vi.mocked(useProviderContext).mockReturnValue({ - enableBilling: true, - isEducationAccount: false, - isEducationWorkspace: false, - isFetchedPlan: false, - plan: { type: Plan.sandbox }, - } as ProviderContextState) - renderWorkspaceCard({ systemFeatures: { deployment_edition: 'ENTERPRISE', @@ -471,13 +454,10 @@ describe('WorkspaceCard', () => { }) it('opens members settings from workspace menu when billing is disabled', async () => { - vi.mocked(useProviderContext).mockReturnValue({ - enableBilling: false, - isEducationAccount: false, - isEducationWorkspace: false, - isFetchedPlan: false, - plan: { type: Plan.sandbox }, - } as ProviderContextState) + mockCurrentWorkspaceQuery({ + ...currentWorkspaceValue, + plan: null, + }) renderWorkspaceCard() diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx index 4c446efa3e5..301a57ef2b7 100644 --- a/web/app/components/main-nav/components/workspace-card.tsx +++ b/web/app/components/main-nav/components/workspace-card.tsx @@ -16,7 +16,6 @@ import LicenseNav from '@/app/components/header/license-env' import { buildIntegrationPath } from '@/app/components/integrations/routes' import { useModalContext } from '@/context/modal-context' import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import Link from '@/next/link' import { consoleQuery } from '@/service/client' @@ -259,26 +258,24 @@ export function WorkspaceCard() { const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions()) const currentWorkspace = currentWorkspaceQuery.data const workspaces = workspacesQuery.data?.workspaces - const { enableBilling } = useProviderContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() - const showCloudBilling = deploymentEdition === 'CLOUD' && enableBilling + const isCloudEdition = deploymentEdition === 'CLOUD' const prefetchWorkspaces = () => { void queryClient.prefetchQuery(workspacesQueryOptions) } if (currentWorkspaceQuery.isPending || !currentWorkspace?.name) { return ( - + ) } const workspacePlan = isWorkspacePlan(currentWorkspace.plan) ? currentWorkspace.plan : null - const isFreePlan = workspacePlan === Plan.sandbox + const hasBillingPlan = typeof currentWorkspace.plan === 'string' + const showCloudBilling = isCloudEdition && hasBillingPlan const showPlanAction = showCloudBilling && workspacePlan !== null + const isFreePlan = workspacePlan === Plan.sandbox const planActionLabel = t( ($) => $[isFreePlan ? 'upgradeBtn.encourageShort' : 'upgradeBtn.plain'], { ns: 'billing' }, @@ -286,7 +283,7 @@ export function WorkspaceCard() { const showInviteMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage') const renderWorkspaceStatus = () => { if (deploymentEdition === 'CLOUD') - return enableBilling && workspacePlan ? : null + return workspacePlan ? : null if (deploymentEdition === 'ENTERPRISE') return return null } @@ -333,7 +330,7 @@ export function WorkspaceCard() { onOpenSettings={() => { setOpen(false) setShowAccountSettingModal({ - payload: enableBilling ? ACCOUNT_SETTING_TAB.BILLING : ACCOUNT_SETTING_TAB.MEMBERS, + payload: hasBillingPlan ? ACCOUNT_SETTING_TAB.BILLING : ACCOUNT_SETTING_TAB.MEMBERS, }) }} onInviteMembers={() => { From 3bc8c69def6d6c0ad739db9ee73b0871c3b2577c Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 28 Jul 2026 18:06:46 +0800 Subject: [PATCH 103/531] feat: label agent conversation reset action on hover (#39691) --- .../preview/__tests__/header.spec.tsx | 16 +++++++++++ .../configure/components/preview/header.tsx | 27 +++++++++++++------ web/i18n/ar-TN/agent-v-2.json | 2 +- web/i18n/de-DE/agent-v-2.json | 2 +- web/i18n/en-US/agent-v-2.json | 2 +- web/i18n/es-ES/agent-v-2.json | 2 +- web/i18n/fa-IR/agent-v-2.json | 2 +- web/i18n/fr-FR/agent-v-2.json | 2 +- web/i18n/hi-IN/agent-v-2.json | 2 +- web/i18n/id-ID/agent-v-2.json | 2 +- web/i18n/it-IT/agent-v-2.json | 2 +- web/i18n/ja-JP/agent-v-2.json | 2 +- web/i18n/ko-KR/agent-v-2.json | 2 +- web/i18n/nl-NL/agent-v-2.json | 2 +- web/i18n/pl-PL/agent-v-2.json | 2 +- web/i18n/pt-BR/agent-v-2.json | 2 +- web/i18n/ro-RO/agent-v-2.json | 2 +- web/i18n/ru-RU/agent-v-2.json | 2 +- web/i18n/sl-SI/agent-v-2.json | 2 +- web/i18n/th-TH/agent-v-2.json | 2 +- web/i18n/tr-TR/agent-v-2.json | 2 +- web/i18n/uk-UA/agent-v-2.json | 2 +- web/i18n/vi-VN/agent-v-2.json | 2 +- web/i18n/zh-Hans/agent-v-2.json | 2 +- web/i18n/zh-Hant/agent-v-2.json | 2 +- 25 files changed, 58 insertions(+), 31 deletions(-) diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx index 1d4a503b779..8b93790baec 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/__tests__/header.spec.tsx @@ -76,6 +76,22 @@ describe('AgentPreviewHeader', () => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) + it.each(['build', 'preview'] as const)( + 'should show the start-fresh tooltip on hover in %s mode', + async (mode) => { + const user = userEvent.setup() + renderHeader({ mode }) + + await user.hover( + screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }), + ) + + expect( + await screen.findByText('agentV2.agentDetail.configure.preview.restart'), + ).toBeInTheDocument() + }, + ) + it('should not emit refresh when the restart button is disabled', async () => { const user = userEvent.setup() const onRefresh = vi.fn() diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx index 084db8fe25d..3f5a7ff0b5b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/header.tsx @@ -6,6 +6,7 @@ import { SegmentedControlDivider, SegmentedControlItem, } from '@langgenius/dify-ui/segmented-control' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { AgentConfigureClearSessionConfirmDialog } from '../confirm-clear-session-dialog' @@ -138,6 +139,7 @@ export function AgentPreviewHeader({ const previewTipBody = t(($) => $['agentDetail.configure.rightPanel.previewTipBody']) const previewDisabledTip = t(($) => $['agentDetail.configure.rightPanel.previewDisabledTip']) const learnMoreLabel = t(($) => $['agentDetail.configure.rightPanel.learnMore']) + const restartLabel = t(($) => $['agentDetail.configure.preview.restart']) const modeTip = `${buildLabel}. ${buildTipBody} ${learnMoreLabel} ${previewLabel}. ${previewTipBody}` const restartButton = ( @@ -202,13 +204,22 @@ export function AgentPreviewHeader({
- {mode === 'preview' ? ( - restartButton - ) : ( - - {restartButton} - - )} + + + {mode === 'preview' ? ( + restartButton + ) : ( + + {restartButton} + + )} + + } + /> + {restartLabel} + {mode === 'build' && showWorkingDirectoryAction && ( } - required - warningDot - isSubTitle - />, - ) - - expect(screen.getByText('Knowledge')).toBeInTheDocument() - expect(screen.getByLabelText('tooltip text')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'operation' })).toBeInTheDocument() - expect(screen.getByText('*')).toBeInTheDocument() - expect(container.querySelector('.system-xs-medium-uppercase')).not.toBeNull() - expect(container.querySelector('.bg-text-warning-secondary')).not.toBeNull() - }) - it('should toggle folded children when supportFold is enabled', () => { - const { container } = render( + render(
folded content
, @@ -33,20 +13,8 @@ describe('Field', () => { fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!) expect(screen.getByText('folded content')).toBeInTheDocument() - expect(container.querySelector('svg')).toHaveStyle({ transform: 'rotate(0deg)' }) fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!) expect(screen.queryByText('folded content')).not.toBeInTheDocument() }) - - it('should render inline children without folding support', () => { - const { container } = render( - -
always visible
-
, - ) - - expect(screen.getByText('always visible')).toBeInTheDocument() - expect(container.firstChild).toHaveClass('flex') - }) }) diff --git a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx index e7bda2fc5da..1a935d2242e 100644 --- a/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx +++ b/web/app/components/workflow/nodes/_base/components/code-generator-button.tsx @@ -3,9 +3,8 @@ import type { FC } from 'react' import type { CodeLanguage } from '../../code/types' import type { GenRes } from '@/service/debug' import { cn } from '@langgenius/dify-ui/cn' -import { useBoolean } from 'ahooks' import * as React from 'react' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { GetCodeGeneratorResModal } from '@/app/components/app/configuration/config/code-generator/get-code-generator-res' import { ActionButton } from '@/app/components/base/action-button' import { Generator } from '@/app/components/base/icons/src/vender/other' @@ -27,20 +26,19 @@ const CodeGenerateBtn: FC = ({ codeLanguages, onGenerated, }) => { - const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = - useBoolean(false) + const [showAutomatic, setShowAutomatic] = useState(false) const handleAutomaticRes = useCallback( (res: GenRes) => { onGenerated?.(res.modified) - showAutomaticFalse() + setShowAutomatic(false) }, - [onGenerated, showAutomaticFalse], + [onGenerated], ) const configsMap = useHooksStore((s) => s.configsMap) return (
- + setShowAutomatic(true)}> {showAutomatic && ( @@ -48,7 +46,7 @@ const CodeGenerateBtn: FC = ({ mode={AppModeEnum.CHAT} isShow={showAutomatic} codeLanguages={codeLanguages} - onClose={showAutomaticFalse} + onClose={() => setShowAutomatic(false)} onFinished={handleAutomaticRes} flowId={configsMap?.flowId || ''} nodeId={nodeId} diff --git a/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx b/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx index f2174f15b1b..4606e7f4cbd 100644 --- a/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { Props as EditorProps } from '.' import type { NodeOutPutVar, Variable } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' -import { useBoolean } from 'ahooks' import * as React from 'react' import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' @@ -29,7 +28,7 @@ const CodeEditor: FC = ({ availableVars, varList, onAddVar, ...editorProp const monacoRef = useRef(null) const popupRef = useRef(null) - const [isShowVarPicker, { setTrue: showVarPicker, setFalse: hideVarPicker }] = useBoolean(false) + const [isShowVarPicker, setIsShowVarPicker] = useState(false) const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 }) @@ -48,9 +47,9 @@ const CodeEditor: FC = ({ availableVars, varList, onAddVar, ...editorProp const popupY = editorRect.top + cursorCoords.top + 20 // Adjust the vertical position as needed setPopupPosition({ x: popupX, y: popupY }) - showVarPicker() + setIsShowVarPicker(true) } else { - hideVarPicker() + setIsShowVarPicker(false) } } @@ -137,7 +136,7 @@ const CodeEditor: FC = ({ availableVars, varList, onAddVar, ...editorProp }, ]) - hideVarPicker() + setIsShowVarPicker(false) } return ( diff --git a/web/app/components/workflow/nodes/_base/components/field.tsx b/web/app/components/workflow/nodes/_base/components/field.tsx index 8325c388936..7c399b6ccaf 100644 --- a/web/app/components/workflow/nodes/_base/components/field.tsx +++ b/web/app/components/workflow/nodes/_base/components/field.tsx @@ -2,8 +2,8 @@ import type { FC, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { RiArrowDownSLine } from '@remixicon/react' -import { useBoolean } from 'ahooks' import * as React from 'react' +import { useState } from 'react' import { Infotip } from '@/app/components/base/infotip' type Props = Readonly<{ @@ -40,7 +40,7 @@ const Field: FC = ({ required, warningDot, }) => { - const [fold, { toggle: toggleFold }] = useBoolean(true) + const [fold, setFold] = useState(true) const tooltipLabel = tooltip ? getTextFromNode(tooltip) || getTextFromNode(title) || 'Help' : undefined @@ -48,7 +48,7 @@ const Field: FC = ({ return (
supportFold && toggleFold()} + onClick={() => supportFold && setFold((isFolded) => !isFolded)} className={cn('flex items-center justify-between', supportFold && 'cursor-pointer')} >
diff --git a/web/app/components/workflow/nodes/_base/components/selector.tsx b/web/app/components/workflow/nodes/_base/components/selector.tsx index f072eff6073..c762462cb0f 100644 --- a/web/app/components/workflow/nodes/_base/components/selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/selector.tsx @@ -1,8 +1,9 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { useBoolean, useClickAway } from 'ahooks' +import { useClickAway } from 'ahooks' import * as React from 'react' +import { useState } from 'react' import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows' import { Check } from '@/app/components/base/icons/src/vender/line/general' @@ -49,10 +50,10 @@ const TypeSelector: FC = ({ const item = allOptions ? allOptions.find((item) => item.value === value) : list.find((item) => item.value === value) - const [showOption, { setFalse: setHide, toggle: toggleShow }] = useBoolean(false) + const [showOption, setShowOption] = useState(false) const ref = React.useRef(null) useClickAway(() => { - setHide() + setShowOption(false) }, ref) return (
= ({ ref={ref} > {trigger ? ( -
+
setShowOption((isShown) => !isShown)} + className={cn(!readonly && 'cursor-pointer')} + > {trigger}
) : (
setShowOption((isShown) => !isShown)} className={cn( showOption && 'bg-state-base-hover', 'flex h-5 cursor-pointer items-center rounded-md pr-0.5 pl-1 text-xs font-semibold text-text-secondary hover:bg-state-base-hover', @@ -96,7 +100,7 @@ const TypeSelector: FC = ({
{ - setHide() + setShowOption(false) onChange(item.value) }} className={cn( diff --git a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx index 55d0c028753..64857e8cb64 100644 --- a/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx +++ b/web/app/components/workflow/nodes/_base/components/switch-plugin-version.tsx @@ -3,7 +3,6 @@ import type { FC, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { useBoolean } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' @@ -29,8 +28,7 @@ export const SwitchPluginVersion: FC = (props) => { const [pluginId] = uniqueIdentifier?.split(':') || [''] const [isShow, setIsShow] = useState(false) - const [isShowUpdateModal, { setTrue: showUpdateModal, setFalse: hideUpdateModal }] = - useBoolean(false) + const [isShowUpdateModal, setIsShowUpdateModal] = useState(false) const [target, setTarget] = useState<{ version: string pluginUniqueIden: string @@ -43,10 +41,10 @@ export const SwitchPluginVersion: FC = (props) => { const pluginDetail = pluginDetails.data?.plugins.at(0) const handleUpdatedFromMarketplace = useCallback(() => { - hideUpdateModal() + setIsShowUpdateModal(false) pluginDetails.refetch() onChange?.(target!.version) - }, [hideUpdateModal, onChange, pluginDetails, target]) + }, [onChange, pluginDetails, target]) const { getIconUrl } = useGetIcon() const icon = pluginDetail?.declaration.icon ? getIconUrl(pluginDetail.declaration.icon) @@ -77,7 +75,7 @@ export const SwitchPluginVersion: FC = (props) => { > {isShowUpdateModal && pluginDetail && ( setIsShowUpdateModal(false)} plugin={pluginManifestToCardPluginProps({ ...pluginDetail.declaration, icon: icon!, @@ -123,7 +121,7 @@ export const SwitchPluginVersion: FC = (props) => { pluginUniqueIden: state.unique_identifier, version: state.version, }) - showUpdateModal() + setIsShowUpdateModal(true) }} trigger={ = ({ name, payload, depth = 1, required, rootClassName }) const isRoot = depth === 1 const hasChildren = payload.type === Type.object && payload.properties const hasEnum = payload.enum && payload.enum.length > 0 - const [fold, { toggle: toggleFold }] = useBoolean(false) + const [fold, setFold] = useState(false) return (
@@ -36,7 +36,7 @@ const Field: FC = ({ name, payload, depth = 1, required, rootClassName }) 'absolute top-[50%] left-[-18px] h-4 w-4 translate-y-[-50%] cursor-pointer bg-components-panel-bg text-text-tertiary', fold && 'rotate-270 text-text-accent', )} - onClick={toggleFold} + onClick={() => setFold((isFolded) => !isFolded)} /> )}
({ onOutputKeyOrdersChange([...outputKeyOrders, newKey]) }, [generateNewKey, inputs, setInputs, onOutputKeyOrdersChange, outputKeyOrders, varKey]) - const [ - isShowRemoveVarConfirm, - { setTrue: showRemoveVarConfirm, setFalse: hideRemoveVarConfirm }, - ] = useBoolean(false) + const [isShowRemoveVarConfirm, setIsShowRemoveVarConfirm] = useState(false) const [removedVar, setRemovedVar] = useState([]) const removeVarInNode = useCallback(() => { const varId = nodesWithInspectVars @@ -129,21 +126,14 @@ function useOutputVarList({ })?.id if (varId) deleteInspectVar(id, varId) removeUsedVarInNodes(removedVar) - hideRemoveVarConfirm() - }, [ - deleteInspectVar, - hideRemoveVarConfirm, - id, - nodesWithInspectVars, - removeUsedVarInNodes, - removedVar, - ]) + setIsShowRemoveVarConfirm(false) + }, [deleteInspectVar, id, nodesWithInspectVars, removeUsedVarInNodes, removedVar]) const handleRemoveVariable = useCallback( (index: number) => { const key = outputKeyOrders[index]! if (isVarUsedInNodes([id, key])) { - showRemoveVarConfirm() + setIsShowRemoveVarConfirm(true) setRemovedVar([id, key]) return } @@ -180,7 +170,6 @@ function useOutputVarList({ onOutputKeyOrdersChange, nodesWithInspectVars, deleteInspectVar, - showRemoveVarConfirm, varKey, ], ) @@ -190,7 +179,7 @@ function useOutputVarList({ handleAddVariable, handleRemoveVariable, isShowRemoveVarConfirm, - hideRemoveVarConfirm, + hideRemoveVarConfirm: () => setIsShowRemoveVarConfirm(false), onRemoveVarConfirm: removeVarInNode, } } diff --git a/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts b/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts index 1f270666326..0563f041745 100644 --- a/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts +++ b/web/app/components/workflow/nodes/http/hooks/use-key-value-list.ts @@ -1,5 +1,4 @@ import type { KeyValue } from '../types' -import { useBoolean } from 'ahooks' import { uniqueId } from 'es-toolkit/compat' import { useCallback, useEffect, useState } from 'react' @@ -63,14 +62,14 @@ const useKeyValueList = (value: string, onChange: (value: string) => void, noFil ]) }, [list, setList]) - const [isKeyValueEdit, { toggle: toggleIsKeyValueEdit }] = useBoolean(true) + const [isKeyValueEdit, setIsKeyValueEdit] = useState(true) return { list: list.length === 0 ? [{ id: uniqueId(UNIQUE_ID_PREFIX), key: '', value: '' }] : list, // no item can not add new item setList, addItem, isKeyValueEdit, - toggleIsKeyValueEdit, + toggleIsKeyValueEdit: () => setIsKeyValueEdit((isEditing) => !isEditing), } } diff --git a/web/app/components/workflow/nodes/http/use-config.ts b/web/app/components/workflow/nodes/http/use-config.ts index 7718f5ead61..dc87c7ce2b4 100644 --- a/web/app/components/workflow/nodes/http/use-config.ts +++ b/web/app/components/workflow/nodes/http/use-config.ts @@ -1,6 +1,5 @@ import type { Var } from '../../types' import type { Authorization, Body, HttpNodeType, Method, Timeout } from './types' -import { useBoolean } from 'ahooks' import { produce } from 'immer' import { useCallback, useEffect, useState } from 'react' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' @@ -113,8 +112,7 @@ const useConfig = (id: string, payload: HttpNodeType) => { ) // authorization - const [isShowAuthorization, { setTrue: showAuthorization, setFalse: hideAuthorization }] = - useBoolean(false) + const [isShowAuthorization, setIsShowAuthorization] = useState(false) const setAuthorization = useCallback( (authorization: Authorization) => { @@ -141,7 +139,7 @@ const useConfig = (id: string, payload: HttpNodeType) => { }, []) // curl import panel - const [isShowCurlPanel, { setTrue: showCurlPanel, setFalse: hideCurlPanel }] = useBoolean(false) + const [isShowCurlPanel, setIsShowCurlPanel] = useState(false) const handleCurlImport = useCallback( (newNode: HttpNodeType) => { @@ -194,14 +192,14 @@ const useConfig = (id: string, payload: HttpNodeType) => { handleSSLVerifyChange, // authorization isShowAuthorization, - showAuthorization, - hideAuthorization, + showAuthorization: () => setIsShowAuthorization(true), + hideAuthorization: () => setIsShowAuthorization(false), setAuthorization, setTimeout, // curl import isShowCurlPanel, - showCurlPanel, - hideCurlPanel, + showCurlPanel: () => setIsShowCurlPanel(true), + hideCurlPanel: () => setIsShowCurlPanel(false), handleCurlImport, } } diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx index 79426136eef..d1565d004a6 100644 --- a/web/app/components/workflow/nodes/human-input/panel.tsx +++ b/web/app/components/workflow/nodes/human-input/panel.tsx @@ -11,10 +11,9 @@ import { RiExpandDiagonalLine, RiEyeLine, } from '@remixicon/react' -import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import * as React from 'react' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' @@ -70,10 +69,10 @@ const Panel: FC> = ({ id, data }) => { }, }) - const [isExpandFormContent, { toggle: toggleExpandFormContent }] = useBoolean(false) + const [isExpandFormContent, setIsExpandFormContent] = useState(false) const nodePanelWidth = useStore((state) => state.nodePanelWidth) - const [isPreview, { toggle: togglePreview, setFalse: hidePreview }] = useBoolean(false) + const [isPreview, setIsPreview] = useState(false) const onAddUseAction = useCallback(() => { const index = inputs.user_actions.length + 1 @@ -131,7 +130,7 @@ const Panel: FC> = ({ id, data }) => { 'flex items-center space-x-1 px-2', isPreview && 'bg-state-accent-active text-text-accent', )} - onClick={togglePreview} + onClick={() => setIsPreview((isPreview) => !isPreview)} >
@@ -160,7 +159,7 @@ const Panel: FC> = ({ id, data }) => { 'flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-text-secondary hover:bg-components-button-ghost-bg-hover', isExpandFormContent && 'bg-state-accent-active text-text-accent', )} - onClick={toggleExpandFormContent} + onClick={() => setIsExpandFormContent((isExpanded) => !isExpanded)} > {isExpandFormContent ? ( @@ -263,7 +262,7 @@ const Panel: FC> = ({ id, data }) => { content={inputs.form_content} formInputs={inputs.inputs} userActions={inputs.user_actions} - onClose={hidePreview} + onClose={() => setIsPreview(false)} /> )}
diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx index 12ff51482e7..96329581225 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset.tsx @@ -1,9 +1,8 @@ 'use client' import type { FC } from 'react' import type { DataSet } from '@/models/datasets' -import { useBoolean } from 'ahooks' import * as React from 'react' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import SelectDataset from '@/app/components/app/configuration/dataset-config/select-dataset' @@ -15,14 +14,14 @@ type Props = Readonly<{ const AddDataset: FC = ({ selectedIds, modal, onChange }) => { const { t } = useTranslation() - const [isShowModal, { setTrue: showModal, setFalse: hideModal }] = useBoolean(false) + const [isShowModal, setIsShowModal] = useState(false) const handleSelect = useCallback( (datasets: DataSet[]) => { onChange(datasets) - hideModal() + setIsShowModal(false) }, - [onChange, hideModal], + [onChange], ) return (
@@ -30,14 +29,14 @@ const AddDataset: FC = ({ selectedIds, modal, onChange }) => { type="button" aria-label={`${t(($) => $['operation.add'], { ns: 'common' })} ${t(($) => $['nodes.knowledgeRetrieval.knowledge'], { ns: 'workflow' })}`} className="cursor-pointer rounded-md border-none bg-transparent p-1 outline-hidden select-none hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid" - onClick={showModal} + onClick={() => setIsShowModal(true)} >