diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index 4596f9062ea..75e79ea2e7f 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -45,6 +45,7 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - 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. @@ -53,12 +54,16 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. 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 `vpr lint --fix --quiet` from the repository root and `pnpm -C e2e type-check`. 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 d7a1a52852b..06177faa5c7 100644 --- a/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md +++ b/.agents/skills/e2e-cucumber-playwright/references/cucumber-best-practices.md @@ -39,6 +39,7 @@ Prefer reuse when: - the user action is genuinely the same - the expected outcome is genuinely the same - the wording stays natural across features +- the parameter is a real product domain value such as a named surface, mode, resource, or status Write a new step when: @@ -46,6 +47,8 @@ Write a new step when: - reusing the old wording would make the scenario misleading - a supposedly generic step would become an implementation-detail wrapper +Do not optimize for a low step count by making vague steps. Optimize for a small set of truthful, domain-owned steps. + ### 4. Prefer Cucumber Expressions Use Cucumber Expressions for parameters unless regex is clearly necessary. @@ -59,6 +62,8 @@ Common examples: Keep expressions readable. If a step needs complicated parsing logic, first ask whether the scenario wording should be simpler. +Use regex for a bounded natural-language alternative only when it keeps Gherkin readable, for example `/(Web app|Backend service API)/`. Avoid broad regexes that accept unowned language. + ### 5. Keep step definitions thin and meaningful Step definitions are glue between Gherkin and automation, not a second abstraction language. 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 02e763d46b6..deb29e72430 100644 --- a/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md +++ b/.agents/skills/e2e-cucumber-playwright/references/playwright-best-practices.md @@ -41,6 +41,7 @@ Also remember: - repeated content usually needs scoping to a stable container - exact text matching is often too brittle when role/name or label already exists - `getByTestId` is acceptable when semantics are weak but the contract is intentional +- when a real UI region, card, status, or icon lacks an accessible name, prefer adding that semantic contract in product code before falling back to `getByTestId` ### 3. Use web-first assertions @@ -62,6 +63,8 @@ Avoid: If a condition genuinely needs custom retry logic, use Playwright's polling/assertion tools deliberately and keep that choice local and explicit. +Use `expect.poll` for non-DOM truth such as API state, backend eventual consistency, generated resources, or captured browser events. For DOM state, use locator assertions so Playwright can apply actionability and web-first retry semantics. + ### 4. Let actions wait for actionability Locator actions already wait for the element to be actionable. Do not preface every click/fill with extra timing logic unless the action needs a specific visible/ready assertion for clarity. diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 69c540c54b4..b04b135ca7d 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -44,6 +44,7 @@ jobs: api-changed: ${{ steps.changes.outputs.api }} cli-changed: ${{ steps.changes.outputs.cli }} e2e-changed: ${{ steps.changes.outputs.e2e }} + external-e2e-changed: ${{ steps.changes.outputs.external_e2e }} web-changed: ${{ steps.changes.outputs.web }} vdb-changed: ${{ steps.changes.outputs.vdb }} migration-changed: ${{ steps.changes.outputs.migration }} @@ -99,6 +100,25 @@ jobs: - 'docker/envs/middleware.env.example' - '.github/workflows/web-e2e.yml' - '.github/actions/setup-web/**' + external_e2e: + - 'e2e/features/agent-v2/**' + - 'e2e/scripts/**' + - 'e2e/support/**' + - '.github/workflows/web-e2e.yml' + - '.github/actions/setup-web/**' + - 'dify-agent/**' + - 'api/clients/agent_backend/**' + - 'api/core/app/apps/agent_app/**' + - 'api/core/workflow/nodes/agent_v2/**' + - 'api/controllers/console/agent/**' + - 'api/services/agent/**' + - 'api/core/plugin/**' + - 'api/services/plugin/**' + - 'api/core/tools/**' + - 'api/services/tools/**' + - 'web/features/agent-v2/**' + - 'web/app/(commonLayout)/roster/**' + - 'web/app/components/workflow/nodes/agent-v2/**' vdb: - 'api/core/rag/datasource/**' - 'api/tests/integration_tests/vdb/**' @@ -322,15 +342,18 @@ jobs: needs: - pre_job - check-changes - if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true' + if: needs.pre_job.outputs.should_skip != 'true' && (needs.check-changes.outputs.e2e-changed == 'true' || needs.check-changes.outputs.external-e2e-changed == 'true') uses: ./.github/workflows/web-e2e.yml + with: + run-external-runtime: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check-changes.outputs.external-e2e-changed == 'true' }} + secrets: inherit web-e2e-skip: name: Skip Web Full-Stack E2E needs: - pre_job - check-changes - if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true' + if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true' && needs.check-changes.outputs.external-e2e-changed != 'true' runs-on: depot-ubuntu-24.04 steps: - name: Report skipped web full-stack e2e @@ -349,7 +372,7 @@ jobs: - name: Finalize Web Full-Stack E2E status env: SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }} - TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }} + TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed == 'true' || needs.check-changes.outputs.external-e2e-changed == 'true' }} RUN_RESULT: ${{ needs.web-e2e-run.result }} SKIP_RESULT: ${{ needs.web-e2e-skip.result }} run: | diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index 2447e9033a3..d50cce65d46 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -2,6 +2,11 @@ name: Web Full-Stack E2E on: workflow_call: + inputs: + run-external-runtime: + required: false + type: boolean + default: false permissions: contents: read @@ -32,7 +37,9 @@ jobs: with: enable-cache: true python-version: "3.12" - cache-dependency-glob: api/uv.lock + cache-dependency-glob: | + api/uv.lock + dify-agent/uv.lock - name: Install API dependencies run: uv sync --project api --dev @@ -51,12 +58,52 @@ jobs: E2E_INIT_PASSWORD: E2eInit12345 run: vp run e2e:full + - name: Run external runtime E2E tests + if: ${{ inputs.run-external-runtime }} + working-directory: ./e2e + env: + E2E_ADMIN_EMAIL: e2e-admin@example.com + E2E_ADMIN_NAME: E2E Admin + E2E_ADMIN_PASSWORD: E2eAdmin12345 + E2E_AGENT_DECISION_MODEL_NAME: ${{ vars.E2E_AGENT_DECISION_MODEL_NAME || 'gpt-5.5' }} + E2E_AGENT_DECISION_MODEL_PROVIDER: ${{ vars.E2E_AGENT_DECISION_MODEL_PROVIDER || 'openai' }} + E2E_AGENT_DECISION_MODEL_TYPE: ${{ vars.E2E_AGENT_DECISION_MODEL_TYPE || 'llm' }} + E2E_EXTERNAL_RUNTIME_SEED_SPECS: ${{ vars.E2E_EXTERNAL_RUNTIME_SEED_SPECS }} + E2E_EXTERNAL_RUNTIME_TAGS: ${{ vars.E2E_EXTERNAL_RUNTIME_TAGS }} + E2E_FORCE_WEB_BUILD: "1" + E2E_INIT_PASSWORD: E2eInit12345 + E2E_MARKETPLACE_API_URL: ${{ vars.E2E_MARKETPLACE_API_URL }} + E2E_MARKETPLACE_PLUGIN_IDS: ${{ vars.E2E_MARKETPLACE_PLUGIN_IDS }} + E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: ${{ vars.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS }} + E2E_MODEL_PROVIDER_CREDENTIALS_JSON: ${{ secrets.E2E_MODEL_PROVIDER_CREDENTIALS_JSON }} + E2E_START_AGENT_BACKEND: ${{ vars.E2E_START_AGENT_BACKEND || '1' }} + E2E_STABLE_MODEL_NAME: ${{ vars.E2E_STABLE_MODEL_NAME || 'gpt-5-nano' }} + E2E_STABLE_MODEL_PROVIDER: ${{ vars.E2E_STABLE_MODEL_PROVIDER || 'openai' }} + E2E_STABLE_MODEL_TYPE: ${{ vars.E2E_STABLE_MODEL_TYPE || 'llm' }} + run: | + if [[ -z "${E2E_MODEL_PROVIDER_CREDENTIALS_JSON}" ]]; then + echo "E2E_MODEL_PROVIDER_CREDENTIALS_JSON is required for external runtime E2E." >&2 + exit 1 + fi + + if [[ -d cucumber-report ]]; then + rm -rf cucumber-report-non-external + mv cucumber-report cucumber-report-non-external + fi + + trap 'vp run e2e:middleware:down' EXIT + vp run e2e:middleware:up + vp run e2e:external:prepare + vp run e2e:external + - name: Upload Cucumber report if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cucumber-report - path: e2e/cucumber-report + path: | + e2e/cucumber-report + e2e/cucumber-report-non-external retention-days: 7 - name: Upload E2E logs diff --git a/api/clients/agent_backend/event_adapter.py b/api/clients/agent_backend/event_adapter.py index e983aa336d7..54c63f15e46 100644 --- a/api/clients/agent_backend/event_adapter.py +++ b/api/clients/agent_backend/event_adapter.py @@ -67,6 +67,7 @@ class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase): type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED output: JsonValue session_snapshot: CompositorSessionSnapshot + usage: dict[str, JsonValue] | None = None class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase): @@ -76,6 +77,7 @@ class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase): deferred_tool_call: DeferredToolCallPayload message: str | None = None session_snapshot: CompositorSessionSnapshot + usage: dict[str, JsonValue] | None = None class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase): @@ -140,6 +142,7 @@ class AgentBackendRunEventAdapter: deferred_tool_call=event.data.deferred_tool_call, message=_deferred_tool_call_message(event.data.deferred_tool_call), session_snapshot=event.data.session_snapshot, + usage=_agent_run_usage(event.data.usage), ) ] return [ @@ -148,6 +151,7 @@ class AgentBackendRunEventAdapter: source_event_id=event.id, output=event.data.output, session_snapshot=event.data.session_snapshot, + usage=_agent_run_usage(event.data.usage), ) ] case RunFailedEvent(): @@ -184,3 +188,13 @@ def _deferred_tool_call_message(payload: DeferredToolCallPayload) -> str: return title return f"Agent backend requested external input via deferred tool '{payload.tool_name}'." + + +def _agent_run_usage(usage: object | None) -> dict[str, JsonValue] | None: + """Return JSON-safe usage metadata from optional Agent backend usage.""" + if usage is None: + return None + dumped = _EVENT_DATA_ADAPTER.dump_python(usage, mode="json") + if not isinstance(dumped, dict): + return None + return cast(dict[str, JsonValue], dumped) diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 0ed8af042dd..3b7b78f299b 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -171,7 +171,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): knowledge: DifyKnowledgeBaseLayerConfig | None = None config_layer_config: DifyConfigLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls - # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. + # through the back proxy, never inline content. drive_config: DifyDriveLayerConfig | None = None # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when # the Agent Soul configures human involvement; a deferred call ends the run and @@ -220,7 +220,7 @@ class AgentBackendAgentAppRunInput(BaseModel): knowledge: DifyKnowledgeBaseLayerConfig | None = None config_layer_config: DifyConfigLayerConfig | None = None # Drive Skills & Files declaration (dify.drive) — an index the agent pulls - # through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED. + # through the back proxy, never inline content. drive_config: DifyDriveLayerConfig | None = None # Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when # the Agent Soul configures human involvement (ENG-635). @@ -254,10 +254,11 @@ class AgentBackendRunRequestBuilder: """Build an Agent App conversation-turn run request. Layer graph: optional Agent Soul system prompt → user prompt → - execution context → optional history (multi-turn) → LLM → optional - plugin-direct tools / core-routed tools / knowledge search → - optional structured output. Mirrors the workflow-node layer ordering - minus the workflow-job / previous-node prompt. + execution context → optional shell / config / drive / history + (multi-turn) → LLM → optional plugin-direct tools / core-routed tools / + knowledge search / ask_human / structured output. Mirrors the + workflow-node layer ordering minus the workflow-job / previous-node + prompt. """ layers: list[RunLayerSpec] = [] if run_input.agent_soul_prompt: @@ -354,11 +355,14 @@ class AgentBackendRunRequestBuilder: ) if run_input.tools is not None and run_input.tools.tools: + plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + if include_shell: + plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID layers.append( RunLayerSpec( name=DIFY_PLUGIN_TOOLS_LAYER_ID, type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=plugin_tool_deps, metadata=run_input.metadata, config=run_input.tools, ) @@ -474,9 +478,9 @@ class AgentBackendRunRequestBuilder: """Build a workflow Agent Node run request without defining another wire schema. Layer graph mirrors the workflow surface: prompts → execution context → - optional drive/history → LLM → optional plugin-direct tools / - core-routed tools / knowledge search → optional auxiliary layers such - as ask_human, shell, and structured output. + optional shell / config / drive / history → LLM → optional + plugin-direct tools / core-routed tools / knowledge search / + ask_human / structured output. """ layers: list[RunLayerSpec] = [] if run_input.agent_soul_prompt: @@ -581,11 +585,14 @@ class AgentBackendRunRequestBuilder: ) if run_input.tools is not None and run_input.tools.tools: + plugin_tool_deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + if include_shell: + plugin_tool_deps["shell"] = DIFY_SHELL_LAYER_ID layers.append( RunLayerSpec( name=DIFY_PLUGIN_TOOLS_LAYER_ID, type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID, - deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}, + deps=plugin_tool_deps, metadata=run_input.metadata, config=run_input.tools, ) diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 58228cbfb02..b59350d5f0d 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import Field, NonNegativeFloat from pydantic_settings import BaseSettings @@ -32,12 +32,10 @@ class AgentBackendConfig(BaseSettings): default=False, ) - AGENT_DRIVE_MANIFEST_ENABLED: bool = Field( + AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS: NonNegativeFloat = Field( description=( - "Inject the dify.drive layer (Skills & Files drive manifest declaration) " - "into Agent runs. The declaration is an index only — the agent backend " - "pulls the actual SKILL.md / files through the back proxy. Set this to " - "false only when temporarily rolling back the drive integration." + "Buffer Agent App assistant text deltas for up to this many seconds before " + "publishing SSE chunks. Set to 0 to publish each delta immediately." ), - default=True, + default=0.5, ) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 377c40877e5..349826e54d7 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -91,33 +91,31 @@ class AgentIdPath(BaseModel): class AgentAppCreatePayload(BaseModel): name: str = Field(..., min_length=1, description="Agent name") description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400) - role: str = Field(..., min_length=1, description="Agent role", max_length=255) + role: str | None = Field(default=None, description="Agent role", max_length=255) icon_type: IconType | None = Field(default=None, description="Icon type") icon: str | None = Field(default=None, description="Icon") icon_background: str | None = Field(default=None, description="Icon background color") @field_validator("role") @classmethod - def validate_role(cls, value: str) -> str: - role = value.strip() - if not role: - raise ValueError("Agent role is required.") - return role + def validate_role(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() # Keep agent-app roster DTOs agent-specific instead of reusing the shared # /apps response/request models. The roster surface needs Agent-only fields such # as `role`, while the generic console/apps contracts must stay unchanged. class AgentAppUpdatePayload(GenericUpdateAppPayload): - role: str = Field(..., min_length=1, description="Agent role", max_length=255) + role: str | None = Field(default=None, description="Agent role", max_length=255) @field_validator("role") @classmethod - def validate_role(cls, value: str) -> str: - role = value.strip() - if not role: - raise ValueError("Agent role is required.") - return role + def validate_role(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() class AgentAppCopyPayload(BaseModel): @@ -133,10 +131,7 @@ class AgentAppCopyPayload(BaseModel): def validate_role(cls, value: str | None) -> str | None: if value is None: return None - role = value.strip() - if not role: - raise ValueError("Agent role is required when provided.") - return role + return value.strip() class AgentApiStatusPayload(BaseModel): @@ -531,6 +526,7 @@ class AgentAppListApi(Resource): page=args.page, limit=args.limit, mode="agent", + sort_by=args.sort_by, name=args.name, tag_ids=args.tag_ids, creator_ids=args.creator_ids, @@ -565,7 +561,7 @@ class AgentAppListApi(Resource): name=args.name, description=args.description, mode="agent", - agent_role=args.role, + agent_role=args.role or "", icon_type=args.icon_type, icon=args.icon, icon_background=args.icon_background, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index c408569703a..1b54f9f9c65 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -1,7 +1,7 @@ import json import logging -from collections.abc import Generator -from typing import Any, Literal +from collections.abc import Generator, Iterator, Mapping +from typing import Any, Literal, Protocol, runtime_checkable from uuid import UUID from flask import request @@ -60,6 +60,11 @@ from services.errors.llm import InvokeRateLimitError logger = logging.getLogger(__name__) +@runtime_checkable +class _ClosableStream(Protocol): + def close(self) -> None: ... + + def _resolve_debugger_chat_streaming( *, app_mode: AppMode, response_mode: str, response_mode_provided: bool = True ) -> bool: @@ -118,15 +123,12 @@ edit workspace files, run validation or debugging commands, make exploratory che Use only the current Build chat message history to identify changes that need to be persisted. Do not inspect, test, or validate old config unless the message history already shows that the old config is invalid. -Persist only the build-draft config resources that need to change, using the Agent config CLI usage provided in the -runtime prompt: +Only update the build-draft config note when the current Build chat contains durable context that later runs need. +Do not create, update, delete, inspect, or fill gaps in other Agent config resources, including config files, config +skills, config env, tools, models, knowledge, or prompt settings. -- config files for reusable artifacts that should be available later, -- config skills for reusable procedures or tools that should be available later, -- config env when environment keys or values need to be recorded, -- config note for concise durable context when useful. - -When updating the config note, record only durable context needed by later runs, such as: +When updating the config note with the Agent config CLI usage provided in the runtime prompt, record only durable +context needed by later runs, such as: - what you installed or configured outside the workspace for this agent, - where those external updates live, including CLI tools, packages, and persistent $HOME paths, @@ -437,7 +439,6 @@ def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[ changes the HTTP boundary: it drains the SSE stream server-side and returns success after the generated build-chat message reaches ``message_end``. """ - close = getattr(response, "close", None) try: for chunk in response: for raw_event in chunk.split("\n\n"): @@ -467,8 +468,8 @@ def _drain_streaming_generate_response(response: RateLimitGenerator | Generator[ if payload_event == "error": raise CompletionRequestError(str(payload.get("message") or "Build chat finalization failed.")) finally: - if callable(close): - close() + if isinstance(response, _ClosableStream): + response.close() raise CompletionRequestError("Build chat finalization did not complete.") @@ -531,6 +532,8 @@ def _generate_chat_message_response( args=args, streaming=streaming, ) + if AppMode.value_of(app_model.mode) == AppMode.AGENT and streaming: + response = _raise_agent_stream_error_before_response(response) return helper.compact_generate_response(response) @@ -543,3 +546,67 @@ def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str): ) return SimpleResultResponse(result="success").model_dump(mode="json"), 200 + + +def _raise_agent_stream_error_before_response(response): + """Surface immediate Agent App stream errors as HTTP errors before SSE starts. + + The shared streaming helper always returns HTTP 200 once the SSE response is + created. Agent v2 configuration errors, such as an invalid model API key, + can be the first real stream event after the initial ping; pre-reading that + first non-ping event lets the console API return the existing 400 error + contract instead of a successful HTTP response carrying only an SSE error. + """ + if isinstance(response, Mapping): + return response + + buffered: list[str] = [] + iterator = iter(response) + while True: + try: + chunk = next(iterator) + except StopIteration: + return iter(buffered) + + if not isinstance(chunk, str): + return _prepend_stream_chunks(buffered, chunk, iterator) + + if _is_sse_ping(chunk): + buffered.append(chunk) + continue + + error_payload = _extract_sse_error_payload(chunk) + if error_payload is not None: + if isinstance(response, _ClosableStream): + response.close() + message = error_payload.get("message") + raise CompletionRequestError(str(message or "Agent App chat failed.")) + + return _prepend_stream_chunks(buffered, chunk, iterator) + + +def _prepend_stream_chunks(buffered: list[Any], first: Any, iterator: Iterator[Any]) -> Generator[Any, None, None]: + yield from buffered + yield first + yield from iterator + + +def _is_sse_ping(chunk: str) -> bool: + return chunk.strip() == "event: ping" + + +def _extract_sse_error_payload(chunk: str) -> dict[str, Any] | None: + for raw_event in chunk.split("\n\n"): + data_lines: list[str] = [] + for line in raw_event.splitlines(): + if line.startswith("data: "): + data_lines.append(line.removeprefix("data: ")) + if not data_lines: + continue + try: + payload = json.loads("\n".join(data_lines)) + except json.JSONDecodeError: + continue + if isinstance(payload, dict) and payload.get("event") == "error": + return payload + return None diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index 661135f2954..249784dee92 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -28,6 +28,7 @@ class PluginUploadQuery(BaseModel): sign: str = Field(..., description="HMAC signature") tenant_id: str = Field(..., description="Tenant identifier") user_id: str | None = Field(default=None, description="User identifier") + conversation_id: str | None = Field(default=None, description="Conversation identifier") register_schema_models(files_ns, PluginUploadQuery) @@ -92,6 +93,7 @@ class PluginUploadFileApi(Resource): mimetype=mimetype, tenant_id=tenant_id, user_id=user.id, + conversation_id=args.conversation_id, timestamp=timestamp, nonce=nonce, sign=sign, @@ -105,7 +107,7 @@ class PluginUploadFileApi(Resource): file_binary=file.stream.read(), mimetype=mimetype, filename=filename, - conversation_id=None, + conversation_id=args.conversation_id, ) extension = guess_extension(tool_file.mimetype) or ".bin" diff --git a/api/controllers/inner_api/plugin/plugin.py b/api/controllers/inner_api/plugin/plugin.py index e1de82c8b68..4e6dbf5dcbe 100644 --- a/api/controllers/inner_api/plugin/plugin.py +++ b/api/controllers/inner_api/plugin/plugin.py @@ -434,6 +434,7 @@ class PluginUploadFileRequestApi(Resource): mimetype=payload.mimetype, tenant_id=tenant_model.id, user_id=user_model.id, + conversation_id=payload.conversation_id, ) return BaseBackwardsInvocationResponse(data={"url": url}).model_dump() diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 6e9a7a79af5..9d45ac71389 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -507,6 +507,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): ), event_adapter=AgentBackendRunEventAdapter(), session_store=AgentAppRuntimeSessionStore(), + text_delta_debounce_seconds=dify_config.AGENT_APP_TEXT_DELTA_DEBOUNCE_SECONDS, ) def _run_input_guards( diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index d90dad1f891..57dac07f761 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -16,6 +16,8 @@ from __future__ import annotations import json import logging +import time +from collections.abc import Mapping from decimal import Decimal from typing import Any, Literal @@ -74,12 +76,23 @@ def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]: return [UserPromptMessage(content=user_query)] +def _llm_usage_from_agent_backend(usage: Mapping[str, Any] | None) -> LLMUsage | None: + if usage is None: + return None + try: + return LLMUsage.from_metadata(usage) + except (TypeError, ValueError): + logger.warning("Failed to parse Agent backend usage metadata: %s", usage, exc_info=True) + return None + + def publish_text_answer( *, queue_manager: AppQueueManager, model_name: str, answer: str, user_query: str | None = None, + usage: LLMUsage | None = None, ) -> None: """Publish a complete assistant answer as one chunk + message-end. @@ -99,6 +112,7 @@ def publish_text_answer( model_name=model_name, answer=answer, user_query=user_query, + usage=usage, ) @@ -127,6 +141,7 @@ def publish_message_end( model_name: str, answer: str, user_query: str | None = None, + usage: LLMUsage | None = None, ) -> None: """Publish the terminal assistant result without emitting another delta.""" prompt_messages = _prompt_messages_from_query(user_query) @@ -136,13 +151,46 @@ def publish_message_end( model=model_name, prompt_messages=prompt_messages, message=AssistantPromptMessage(content=answer), - usage=LLMUsage.empty_usage(), + usage=usage or LLMUsage.empty_usage(), ), ), PublishFrom.APPLICATION_MANAGER, ) +class _TextDeltaDebouncer: + """Batch assistant text deltas on stream-event boundaries for final SSE output.""" + + def __init__(self, *, debounce_seconds: float) -> None: + self._debounce_seconds = debounce_seconds + self._parts: list[str] = [] + self._first_pending_at: float | None = None + + def push(self, delta: str) -> str | None: + if not delta: + return None + if self._debounce_seconds <= 0: + return delta + + now = time.monotonic() + if not self._parts: + self._first_pending_at = now + self._parts.append(delta) + + if self._first_pending_at is not None and now - self._first_pending_at >= self._debounce_seconds: + return self.flush() + return None + + def flush(self) -> str | None: + if not self._parts: + return None + + text = "".join(self._parts) + self._parts = [] + self._first_pending_at = None + return text + + class _AgentProcessRecorder: """Persist Agent v2 thinking/tool process events through the legacy thought model.""" @@ -430,11 +478,13 @@ class AgentAppRunner: agent_backend_client: AgentBackendRunClient, event_adapter: AgentBackendRunEventAdapter, session_store: AgentAppRuntimeSessionStore, + text_delta_debounce_seconds: float, ) -> None: self._request_builder = request_builder self._agent_backend_client = agent_backend_client self._event_adapter = event_adapter self._session_store = session_store + self._text_delta_debounce_seconds = text_delta_debounce_seconds def run( self, @@ -512,6 +562,7 @@ class AgentAppRunner: answer=answer, query=query, streamed_answer=streamed_answer, + usage=_llm_usage_from_agent_backend(terminal.usage), ) self._save_session( scope=scope, @@ -724,19 +775,39 @@ class AgentAppRunner: model_name: str, query: str | None, ): + """Consume backend events while preserving raw recorder granularity. + + Process events are recorded immediately for observability. Only the + final assistant text deltas sent through the EasyUI queue are debounced, + with flushes happening on later stream events or terminal boundaries. + """ terminal = None streamed_answer_parts: list[str] = [] + text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds) process_recorder = _AgentProcessRecorder( dify_context=dify_context, message_id=message_id, queue_manager=queue_manager, ) + + def flush_pending_text() -> None: + pending_text = text_delta_debouncer.flush() + if pending_text: + publish_text_delta( + queue_manager=queue_manager, + model_name=model_name, + delta=pending_text, + user_query=query, + ) + for public_event in self._agent_backend_client.stream_events(run_id): if queue_manager.is_stopped(): + flush_pending_text() self._cancel_run(run_id) raise GenerateTaskStoppedError() for internal_event in self._event_adapter.adapt(public_event): if queue_manager.is_stopped(): + flush_pending_text() self._cancel_run(run_id) raise GenerateTaskStoppedError() if internal_event.type in ( @@ -758,18 +829,22 @@ class AgentAppRunner: text_delta = self._extract_stream_text_delta(internal_event) if text_delta: streamed_answer_parts.append(text_delta) - publish_text_delta( - queue_manager=queue_manager, - model_name=model_name, - delta=text_delta, - user_query=query, - ) + debounced_delta = text_delta_debouncer.push(text_delta) + if debounced_delta: + publish_text_delta( + queue_manager=queue_manager, + model_name=model_name, + delta=debounced_delta, + user_query=query, + ) continue continue + flush_pending_text() terminal = internal_event break if terminal is not None: break + flush_pending_text() return terminal, "".join(streamed_answer_parts) def _cancel_run(self, run_id: str) -> None: @@ -793,10 +868,20 @@ class AgentAppRunner: answer: str, query: str | None, streamed_answer: str, + usage: LLMUsage | None, ) -> None: """Finish a successful streamed turn without duplicating the final text.""" + if not answer and streamed_answer: + answer = streamed_answer + if not streamed_answer: - self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query) + publish_text_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=answer, + user_query=query, + usage=usage, + ) return if answer.startswith(streamed_answer): @@ -812,7 +897,13 @@ class AgentAppRunner: "using terminal output for message persistence." ) - publish_message_end(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query) + publish_message_end( + queue_manager=queue_manager, + model_name=model_name, + answer=answer, + user_query=query, + usage=usage, + ) def _save_session( self, @@ -852,6 +943,8 @@ class AgentAppRunner: configured the value is a JSON object, which we serialize so the chat message always has a string body. """ + if output is None: + return "" if isinstance(output, str): return output if isinstance(output, dict): diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index 4e850a4887a..fb1d054e750 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -46,7 +46,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import ( ) from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig from models.provider_ids import ModelProviderID -from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions +from services.agent.prompt_mentions import expand_prompt_mentions class AgentAppRuntimeRequestBuildError(ValueError): @@ -124,17 +124,14 @@ class AgentAppRuntimeRequestBuilder: "cli_tool_count": len(agent_soul.tools.cli_tools), } - config_layer_config = None - soul_prompt_resolver = build_soul_mention_resolver(agent_soul) - if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: - config_layer_config, config_warnings = build_config_layer_config( - agent_soul, - agent_id=context.agent_id, - config_version_id=context.agent_config_snapshot_id, - config_version_kind=context.agent_config_version_kind, - ) - append_runtime_warnings(metadata, config_warnings) - soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) + config_layer_config, config_warnings = build_config_layer_config( + agent_soul, + agent_id=context.agent_id, + config_version_id=context.agent_config_snapshot_id, + config_version_kind=context.agent_config_version_kind, + ) + append_runtime_warnings(metadata, config_warnings) + soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) knowledge_config = build_knowledge_layer_config(agent_soul) request = self._request_builder.build_for_agent_app( diff --git a/api/core/helper/marketplace.py b/api/core/helper/marketplace.py index 2c7d520250c..0b77891ce16 100644 --- a/api/core/helper/marketplace.py +++ b/api/core/helper/marketplace.py @@ -1,5 +1,6 @@ import logging from collections.abc import Sequence +from urllib.parse import urlencode import httpx from yarl import URL @@ -16,7 +17,8 @@ MARKETPLACE_TIMEOUT = 30 def get_plugin_pkg_url(plugin_unique_identifier: str) -> str: - return str((marketplace_api_url / "api/v1/plugins/download").with_query(unique_identifier=plugin_unique_identifier)) + query = urlencode({"unique_identifier": plugin_unique_identifier}) + return f"{marketplace_api_url / 'api/v1/plugins/download'}?{query}" def download_plugin_pkg(plugin_unique_identifier: str): diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py index fecad81c032..65f7d044ea2 100644 --- a/api/core/plugin/entities/request.py +++ b/api/core/plugin/entities/request.py @@ -230,6 +230,7 @@ class RequestRequestUploadFile(BaseModel): filename: str mimetype: str + conversation_id: str | None = None class RequestDownloadFileMapping(BaseModel): diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 805c52329b3..6b306e2df86 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -902,7 +902,10 @@ class PluginService: tenant_id, plugin_unique_identifiers, PluginInstallationSource.Package, - [{}], + [ + {"plugin_unique_identifier": plugin_unique_identifier} + for plugin_unique_identifier in plugin_unique_identifiers + ], ) PluginService.invalidate_plugin_model_providers_cache(tenant_id) return result diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index 96c6e57e7b0..c1ba9964a3a 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -1159,6 +1159,39 @@ class DatasetRetrieval: all_documents.extend(documents) + def _run_retriever_thread( + self, + *, + flask_app: Flask, + dataset_id: str, + query: str | None, + top_k: int, + all_documents: list[Document], + document_ids_filter: list[str] | None, + metadata_condition: MetadataFilteringCondition | None, + attachment_ids: list[str] | None, + cancel_event: threading.Event | None, + thread_exceptions: list[Exception] | None, + ) -> None: + try: + with session_factory.create_session() as session: + self._retriever( + flask_app=flask_app, + session=session, + dataset_id=dataset_id, + query=query or "", + top_k=top_k, + all_documents=all_documents, + document_ids_filter=document_ids_filter, + metadata_condition=metadata_condition, + attachment_ids=attachment_ids, + ) + except Exception as e: + if cancel_event: + cancel_event.set() + if thread_exceptions is not None: + thread_exceptions.append(e) + def to_dataset_retriever_tool( self, session: Session, @@ -1797,7 +1830,7 @@ class DatasetRetrieval: else: continue retrieval_thread = threading.Thread( - target=self._retriever, + target=self._run_retriever_thread, kwargs={ "flask_app": flask_app, "dataset_id": dataset.id, @@ -1807,6 +1840,8 @@ class DatasetRetrieval: "document_ids_filter": document_ids_filter, "metadata_condition": metadata_condition, "attachment_ids": [attachment_id] if attachment_id else None, + "cancel_event": cancel_event, + "thread_exceptions": thread_exceptions, }, ) threads.append(retrieval_thread) diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py index ca4756f2a4c..77d81bafbc1 100644 --- a/api/core/tools/signature.py +++ b/api/core/tools/signature.py @@ -64,34 +64,45 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT -def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, user_id: str) -> str: +def get_signed_file_url_for_plugin( + filename: str, mimetype: str, tenant_id: str, user_id: str, conversation_id: str | None = None +) -> str: """Build the signed upload URL used by the plugin-facing file upload endpoint.""" base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL upload_url = f"{base_url}/files/upload/for-plugin" timestamp = str(int(time.time())) nonce = os.urandom(16).hex() - data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" + data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}" sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() encoded_sign = base64.urlsafe_b64encode(sign).decode() - query = urllib.parse.urlencode( - { - "timestamp": timestamp, - "nonce": nonce, - "sign": encoded_sign, - "user_id": user_id, - "tenant_id": tenant_id, - } - ) + query_params = { + "timestamp": timestamp, + "nonce": nonce, + "sign": encoded_sign, + "user_id": user_id, + "tenant_id": tenant_id, + } + if conversation_id: + query_params["conversation_id"] = conversation_id + query = urllib.parse.urlencode(query_params) return f"{upload_url}?{query}" def verify_plugin_file_signature( - *, filename: str, mimetype: str, tenant_id: str, user_id: str, timestamp: str, nonce: str, sign: str + *, + filename: str, + mimetype: str, + tenant_id: str, + user_id: str, + conversation_id: str | None = None, + timestamp: str, + nonce: str, + sign: str, ) -> bool: """Verify the signature used by the plugin-facing file upload endpoint.""" - data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}" + data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}" recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest() recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode() diff --git a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py index acb58a46952..fc2719a6204 100644 --- a/api/core/workflow/nodes/agent_v2/dify_tools_builder.py +++ b/api/core/workflow/nodes/agent_v2/dify_tools_builder.py @@ -11,6 +11,7 @@ from dify_agent.layers.dify_plugin import ( DifyPluginToolCredentialType, DifyPluginToolParameter, DifyPluginToolParameterForm, + DifyPluginToolParameterType, DifyPluginToolsLayerConfig, ) from sqlalchemy import select @@ -351,7 +352,9 @@ class WorkflowAgentDifyToolsBuilder: @staticmethod def _tool_layer_destination(tool_config: AgentSoulDifyToolConfig) -> Literal["plugin", "core"]: provider_type = ToolProviderType.value_of(tool_config.provider_type) - if provider_type is ToolProviderType.PLUGIN: + if provider_type is ToolProviderType.PLUGIN or ( + provider_type is ToolProviderType.BUILT_IN and _is_plugin_provider_id(tool_config.provider_id) + ): return "plugin" if provider_type in { ToolProviderType.BUILT_IN, @@ -404,7 +407,7 @@ class WorkflowAgentDifyToolsBuilder: credentials=self._normalize_credentials(runtime.credentials, tool_name=exposed_name), runtime_parameters=runtime_parameters, parameters=parameters, - parameters_json_schema=tool_runtime.get_llm_parameters_json_schema(), + parameters_json_schema=self._plugin_parameters_json_schema(tool_runtime, parameters), ) def _to_core_backend_tool_config( @@ -456,6 +459,41 @@ class WorkflowAgentDifyToolsBuilder: description = tool_runtime.entity.description.llm return description + @staticmethod + def _plugin_parameters_json_schema( + tool_runtime: Tool, + parameters: list[DifyPluginToolParameter], + ) -> dict[str, Any]: + schema = tool_runtime.get_llm_parameters_json_schema() + properties = schema.setdefault("properties", {}) + required = schema.setdefault("required", []) + if not isinstance(properties, dict) or not isinstance(required, list): + raise WorkflowAgentDifyToolsBuildError( + "agent_tool_declaration_invalid", + f"Dify Plugin Tool {tool_runtime.entity.identity.name!r} has invalid parameter schema.", + ) + + for parameter in parameters: + if parameter.form is not DifyPluginToolParameterForm.LLM: + continue + if parameter.type is DifyPluginToolParameterType.FILE: + properties[parameter.name] = _plugin_file_input_schema(parameter.llm_description or "") + elif parameter.type in { + DifyPluginToolParameterType.FILES, + DifyPluginToolParameterType.SYSTEM_FILES, + }: + properties[parameter.name] = { + "type": "array", + "items": _plugin_file_input_schema(parameter.llm_description or ""), + "description": parameter.llm_description or "", + } + else: + continue + + if parameter.required and parameter.name not in required: + required.append(parameter.name) + return schema + @staticmethod def _runtime_parameters( tool_runtime: Tool, @@ -498,3 +536,44 @@ class WorkflowAgentDifyToolsBuilder: ), ) return normalized + + +def _is_plugin_provider_id(provider_id: str | None) -> bool: + if not provider_id: + return False + parts = provider_id.split("/") + return len(parts) == 3 and all(parts) + + +def _plugin_file_input_schema(description: str) -> dict[str, Any]: + return { + "description": description, + "anyOf": [ + { + "type": "string", + "minLength": 1, + "description": "HTTP(S) URL or sandbox-local file path.", + }, + { + "type": "object", + "additionalProperties": False, + "required": ["transfer_method", "url"], + "properties": { + "transfer_method": {"type": "string", "enum": ["remote_url"]}, + "url": {"type": "string", "minLength": 1}, + }, + }, + { + "type": "object", + "additionalProperties": False, + "required": ["transfer_method", "reference"], + "properties": { + "transfer_method": { + "type": "string", + "enum": ["local_file", "tool_file", "datasource_file"], + }, + "reference": {"type": "string", "minLength": 1}, + }, + }, + ], + } diff --git a/api/core/workflow/nodes/agent_v2/output_adapter.py b/api/core/workflow/nodes/agent_v2/output_adapter.py index 0fae0105348..b5ac93df6ae 100644 --- a/api/core/workflow/nodes/agent_v2/output_adapter.py +++ b/api/core/workflow/nodes/agent_v2/output_adapter.py @@ -333,6 +333,8 @@ class WorkflowAgentOutputAdapter: session_snapshot = None if isinstance(event, AgentBackendRunSucceededInternalEvent | AgentBackendDeferredToolCallInternalEvent): session_snapshot = event.session_snapshot + if event.usage is not None: + agent_backend["usage"] = dict(event.usage) if session_snapshot is not None: agent_backend["session_snapshot"] = { "layer_count": len(session_snapshot.layers), diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 5804fd5fdcd..1a4529b5d97 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -206,17 +206,14 @@ class WorkflowAgentRuntimeRequestBuilder: "cli_tool_count": len(agent_soul.tools.cli_tools), } - config_layer_config: DifyConfigLayerConfig | None = None - soul_prompt_resolver = build_soul_mention_resolver(agent_soul) - if dify_config.AGENT_DRIVE_MANIFEST_ENABLED: - config_layer_config, config_warnings = build_config_layer_config( - agent_soul, - agent_id=context.agent.id, - config_version_id=context.snapshot.id, - config_version_kind="snapshot", - ) - append_runtime_warnings(metadata, config_warnings) - soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) + config_layer_config, config_warnings = build_config_layer_config( + agent_soul, + agent_id=context.agent.id, + config_version_id=context.snapshot.id, + config_version_kind="snapshot", + ) + append_runtime_warnings(metadata, config_warnings) + soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul) soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() knowledge_config = build_knowledge_layer_config(agent_soul) @@ -717,7 +714,7 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi for tool in (_shell_cli_tool(item) for item in agent_soul.tools.cli_tools if _cli_tool_enabled(item)) if tool is not None ], - env=[env for env in (_shell_env_var(item) for item in agent_soul.env.variables) if env is not None], + env=_shell_env_vars(agent_soul.env.variables, agent_soul.env.secret_refs), secret_refs=[ secret for secret in (_shell_secret_ref(item) for item in agent_soul.env.secret_refs) if secret is not None ], @@ -864,8 +861,13 @@ def build_config_layer_config( agent_id: str | None = None, config_version_id: str | None = None, config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot", -) -> tuple[DifyConfigLayerConfig | None, list[dict[str, str]]]: - """Derive prompt-mentioned eager-pull names from Agent Soul.""" +) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]: + """Build the always-present Agent config layer from Agent Soul state. + + The ``dify.config`` layer must exist for every Agent v2 runtime request so + the backend can expose the config CLI/help surface even when the current + Agent Soul has no config assets, note, or prompt mentions. + """ ordered_mentions = list( dict.fromkeys( @@ -874,14 +876,6 @@ def build_config_layer_config( if mention.kind in {MentionKind.SKILL, MentionKind.FILE} and mention.ref_id ) ) - if ( - not agent_soul.config_skills - and not agent_soul.config_files - and not agent_soul.config_note - and not ordered_mentions - ): - return None, [] - skill_names = {skill.name for skill in agent_soul.config_skills} file_names = {file_ref.name for file_ref in agent_soul.config_files} warnings: list[dict[str, str]] = [] @@ -968,11 +962,7 @@ def _shell_cli_tool(item: object) -> DifyShellCliToolConfig | None: if not commands and not isinstance(name, str): return None tool_env = data.get("env") if isinstance(data.get("env"), Mapping) else {} - env = [ - env_var - for env_var in (_shell_env_var(item) for item in _env_entries(tool_env, "variables")) - if env_var is not None - ] + env = _shell_env_vars(_env_entries(tool_env, "variables"), _env_entries(tool_env, "secret_refs")) secret_refs = [ secret_ref for secret_ref in (_shell_secret_ref(item) for item in _env_entries(tool_env, "secret_refs")) @@ -995,6 +985,12 @@ def _env_entries(env: object, key: str) -> list[object]: return entries +def _shell_env_vars(variables: Sequence[object], secret_refs: Sequence[object]) -> list[DifyShellEnvVarConfig]: + env_vars = [_shell_env_var(item) for item in variables] + secret_env_vars = [_shell_env_var(item) for item in secret_refs if _has_secret_value(item)] + return [env for env in [*env_vars, *secret_env_vars] if env is not None] + + def _shell_env_var(item: object) -> DifyShellEnvVarConfig | None: data = _plain_mapping(item) name = _name_from_mapping(data) @@ -1011,13 +1007,15 @@ def _shell_secret_ref(item: object) -> DifyShellSecretRefConfig | None: name = _name_from_mapping(data) if name is None: return None - ref = ( - data.get("ref") - or data.get("value") - or data.get("id") - or data.get("credential_id") - or data.get("provider_credential_id") - ) + # Inline Composer values are passed as env vars because the agent-backend + # secret ref schema only accepts short backend-managed reference IDs. + if _has_secret_value(item): + return None + ref = data.get("ref") or data.get("credential_id") or data.get("provider_credential_id") + if ref is None: + ref = data.get("id") + if ref is None: + return None return DifyShellSecretRefConfig(name=name, ref=str(ref) if ref is not None else None) @@ -1029,6 +1027,12 @@ def _plain_mapping(item: object) -> dict[str, Any]: return {} +def _has_secret_value(item: object) -> bool: + data = _plain_mapping(item) + value = data.get("value") + return isinstance(value, str) and bool(value) + + def _name_from_mapping(item: Mapping[str, Any]) -> str | None: for key in ("name", "key", "env_name", "variable"): value = item.get(key) diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index 64990357acd..64fe85930c6 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -250,9 +250,10 @@ class AgentSecretRefConfig(AgentFlexibleConfig): env_name: str | None = Field(default=None, max_length=255) variable: str | None = Field(default=None, max_length=255) type: str | None = Field(default=None, max_length=64) - # UI-facing selected secret reference. This is a credential/ref id, not the - # plaintext secret value; runtime maps it to the shell-layer ``ref``. - value: str | None = Field(default=None, max_length=255) + # User-provided secret value. Long API tokens are valid here; runtime maps + # this field into a shell env var, while ref/id/credential_id fields keep the + # backend-managed secret reference path. + value: str | None = None id: str | None = Field(default=None, max_length=255) ref: str | None = Field(default=None, max_length=255) credential_id: str | None = Field(default=None, max_length=255) @@ -515,9 +516,18 @@ class AgentTextToSpeechFeatureConfig(AgentFeatureToggleConfig): autoPlay: str | None = None +class AgentSuggestedQuestionsAfterAnswerModelConfig(AgentFlexibleConfig): + """Legacy Chat App model config used only for follow-up question generation.""" + + provider: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) + mode: str | None = Field(default=None, max_length=64) + completion_params: dict[str, Any] | None = None + + class AgentSuggestedQuestionsAfterAnswerFeatureConfig(AgentFeatureToggleConfig): prompt: str | None = None - model: AgentSoulModelConfig | None = None + model: AgentSuggestedQuestionsAfterAnswerModelConfig | None = None class AgentModerationIOConfig(AgentFlexibleConfig): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index d261e338bb1..9b1341526b8 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -12935,7 +12935,7 @@ Default namespace | icon_background | string | Icon background color | No | | icon_type | [IconType](#icontype) | Icon type | No | | name | string | Agent name | Yes | -| role | string | Agent role | Yes | +| role | string | Agent role | No | #### AgentAppDetailWithSite @@ -13062,7 +13062,7 @@ default (the config form sends the full desired feature state on save). | icon_type | [IconType](#icontype) | Icon type | No | | max_active_requests | integer | Maximum active requests | No | | name | string | App name | Yes | -| role | string | Agent role | Yes | +| role | string | Agent role | No | | use_icon_as_answer_icon | boolean | Use icon as answer icon | No | #### AgentAverageResponseTimeStatisticResponse @@ -14569,9 +14569,20 @@ Soft lifecycle state for Agent records. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | enabled | boolean | | No | -| model | [AgentSoulModelConfig](#agentsoulmodelconfig) | | No | +| model | [AgentSuggestedQuestionsAfterAnswerModelConfig](#agentsuggestedquestionsafteranswermodelconfig) | | No | | prompt | string | | No | +#### AgentSuggestedQuestionsAfterAnswerModelConfig + +Legacy Chat App model config used only for follow-up question generation. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completion_params | object | | No | +| mode | string | | No | +| name | string | | Yes | +| provider | string | | Yes | + #### AgentTextToSpeechFeatureConfig | Name | Type | Description | Required | diff --git a/api/services/agent/observability_service.py b/api/services/agent/observability_service.py index 76b36abed2a..a209b8de0f0 100644 --- a/api/services/agent/observability_service.py +++ b/api/services/agent/observability_service.py @@ -619,9 +619,7 @@ WHERE @staticmethod def _statistics_message_scope_sql(source_filter: AgentSourceFilter) -> str: app_scope = "m.app_id = :app_id" - if source_filter.invoke_from is None: - app_scope += " AND m.invoke_from != :debugger" - else: + if source_filter.invoke_from is not None: app_scope += " AND m.invoke_from = :source" workflow_binding_filters = [] if source_filter.app_id: diff --git a/api/services/agent/skill_package_service.py b/api/services/agent/skill_package_service.py index 3974d306a14..d5601987abc 100644 --- a/api/services/agent/skill_package_service.py +++ b/api/services/agent/skill_package_service.py @@ -175,7 +175,10 @@ class SkillPackageService: continue raise SkillPackageError( "files_outside_skill_root", - "skill archive contains files outside the selected skill root", + ( + "skill package must contain exactly one skill; " + "multiple skill folders in one archive are not supported" + ), status_code=400, ) normalized_path = safe_path.removeprefix(skill_root_prefix) diff --git a/api/services/agent_config_service.py b/api/services/agent_config_service.py index af05acb9ef7..a0da08e5c2e 100644 --- a/api/services/agent_config_service.py +++ b/api/services/agent_config_service.py @@ -496,13 +496,16 @@ class AgentConfigService: user_id=user_id, ) self._require_writable(target, surface=surface) - skill_ref, _package = self._skill_normalizer.normalize( - content=content, - filename=filename, - requested_name=None, - tenant_id=tenant_id, - user_id=user_id, - ) + try: + skill_ref, _package = self._skill_normalizer.normalize( + content=content, + filename=filename, + requested_name=None, + tenant_id=tenant_id, + user_id=user_id, + ) + except SkillPackageError as exc: + raise AgentConfigServiceError(exc.code, exc.message, status_code=exc.status_code) from exc agent_soul = target.agent_soul.model_copy(deep=True) existing = {item.name: item for item in agent_soul.config_skills} order = [item.name for item in agent_soul.config_skills] diff --git a/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py index ab88ba19640..f6c73fdb66d 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py +++ b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py @@ -1,6 +1,7 @@ import pytest from agenton.compositor import CompositorSessionSnapshot from dify_agent.protocol import ( + AgentRunUsage, DeferredToolCallPayload, PydanticAIStreamRunEvent, RunCancelledEvent, @@ -59,7 +60,11 @@ def test_event_adapter_maps_run_succeeded_to_final_output(): RunSucceededEvent( id="3-0", run_id="run-1", - data=RunSucceededEventData(output={"summary": "done"}, session_snapshot=snapshot), + data=RunSucceededEventData( + output={"summary": "done"}, + session_snapshot=snapshot, + usage=AgentRunUsage(prompt_tokens=2, completion_tokens=3), + ), ) ) @@ -69,6 +74,7 @@ def test_event_adapter_maps_run_succeeded_to_final_output(): source_event_id="3-0", output={"summary": "done"}, session_snapshot=snapshot, + usage={"prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5}, ) ] 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 e070a98c12a..2e5851349d4 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 @@ -318,7 +318,9 @@ def test_agent_app_list_and_create_use_agent_route( lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ) - with app.test_request_context("/console/api/agent?page=1&limit=10&mode=workflow"): + with app.test_request_context( + "/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created&is_created_by_me=true" + ): listed = unwrap(AgentAppListApi.get)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) assert listed["page"] == 1 @@ -343,6 +345,8 @@ def test_agent_app_list_and_create_use_agent_route( list_call = cast(dict[str, object], captured["list"]) list_params = cast(Any, list_call["params"]) assert list_params.mode == "agent" + assert list_params.sort_by == "recently_created" + assert list_params.is_created_by_me is True assert list_params.status == "normal" with app.test_request_context( @@ -370,20 +374,54 @@ def test_agent_app_list_and_create_use_agent_route( assert create_params.agent_role == "Coordinator" -def test_agent_app_create_requires_role(app: Flask, account_id: str) -> None: - with app.test_request_context( - "/console/api/agent", - json={"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}, - ): - with pytest.raises(ValueError, match="Field required"): - unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) +def test_agent_app_create_payload_allows_optional_role() -> None: + omitted = roster_controller.AgentAppCreatePayload.model_validate( + {"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"} + ) + blank = roster_controller.AgentAppCreatePayload.model_validate( + {"name": "Iris", "description": "Agent app", "role": " ", "icon_type": "emoji", "icon": "robot"} + ) + assert omitted.role is None + assert blank.role == "" + + +def test_agent_app_create_omits_optional_role_as_empty_string( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + captured: dict[str, object] = {} + + class FakeAppService: + def create_app(self, tenant_id: str, params: object, account: object) -> object: + captured["create"] = {"tenant_id": tenant_id, "params": params, "account": account} + return _app_detail_obj(id="app-created", bound_agent_id="agent-created") + + monkeypatch.setattr(roster_controller, "AppService", FakeAppService) + monkeypatch.setattr( + roster_controller, + "_serialize_agent_app_detail", + lambda app_model, **_kwargs: {"id": "agent-created", "app_id": app_model.id}, + ) + + current_user = SimpleNamespace(id=account_id) with app.test_request_context( "/console/api/agent", - json={"name": "Iris", "description": "Agent app", "role": " ", "icon_type": "emoji", "icon": "robot"}, + json={ + "name": "No-role Iris", + "description": "Agent app", + "icon_type": "emoji", + "icon": "robot", + }, ): - with pytest.raises(ValueError, match="Agent role is required"): - unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", SimpleNamespace(id=account_id)) + created, status = unwrap(AgentAppListApi.post)(AgentAppListApi(), "tenant-1", current_user) + + assert status == 201 + assert created == {"id": "agent-created", "app_id": "app-created"} + create_call = cast(dict[str, object], captured["create"]) + create_params = cast(Any, create_call["params"]) + assert create_call["tenant_id"] == "tenant-1" + assert create_call["account"] is current_user + assert create_params.agent_role == "" def test_agent_app_detail_update_delete_resolve_app_from_agent_id( @@ -805,7 +843,7 @@ def test_agent_api_status_and_key_routes_resolve_backing_app( } -def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: +def test_agent_app_update_allows_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = "00000000-0000-0000-0000-000000000001" app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id) captured: dict[str, object] = {} @@ -820,11 +858,28 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon "get_app_backing_agent", lambda _self, **kwargs: SimpleNamespace( id=agent_id, + app_id="app-1", + backing_app_id=None, role="", debug_conversation_id="debug-conversation-detail", active_config_snapshot_id=None, ), ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-detail", + ) + 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", @@ -845,8 +900,11 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon "/console/api/agent/00000000-0000-0000-0000-000000000001", json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"}, ): - with pytest.raises(ValueError, match="String should have at least 1 character"): - unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id) + updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id) + + assert updated["role"] == "" + update_call = cast(dict[str, object], captured["update"]) + assert cast(dict[str, object], update_call["args"])["role"] == "" def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -1369,6 +1427,56 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"} +def test_agent_chat_stream_preflight_raises_first_error_event() -> None: + class ClosableStream: + def __init__(self) -> None: + self.closed = False + self._chunks = iter( + [ + "event: ping\n\n", + ( + 'data: {"event":"error","message":"Incorrect API key provided",' + '"code":"completion_request_error","status":400}\n\n' + ), + ] + ) + + def __iter__(self): + return self + + def __next__(self) -> str: + return next(self._chunks) + + def close(self) -> None: + self.closed = True + + stream = ClosableStream() + + with pytest.raises(CompletionRequestError) as exc_info: + completion_controller._raise_agent_stream_error_before_response(stream) + + assert "Incorrect API key provided" in exc_info.value.description + assert stream.closed is True + + +def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None: + stream = iter( + [ + "event: ping\n\n", + 'data: {"event":"message","answer":"hello"}\n\n', + 'data: {"event":"message_end"}\n\n', + ] + ) + + wrapped = completion_controller._raise_agent_stream_error_before_response(stream) + + assert list(wrapped) == [ + "event: ping\n\n", + 'data: {"event":"message","answer":"hello"}\n\n', + 'data: {"event":"message_end"}\n\n', + ] + + def test_agent_build_chat_finalize_route_resolves_app_from_agent_id( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: diff --git a/api/tests/unit_tests/controllers/files/test_upload.py b/api/tests/unit_tests/controllers/files/test_upload.py index 5281dcf0645..4d5522c0c6c 100644 --- a/api/tests/unit_tests/controllers/files/test_upload.py +++ b/api/tests/unit_tests/controllers/files/test_upload.py @@ -65,6 +65,7 @@ class TestPluginUploadFileApi: "sign": "sig", "tenant_id": "tenant-1", "user_id": "user-1", + "conversation_id": "conversation-1", }, file=dummy_file, ) @@ -83,6 +84,10 @@ class TestPluginUploadFileApi: assert result["id"] == "file-id" assert result["reference"] == build_file_reference(record_id="file-id") assert result["preview_url"] == "signed-url" + mock_verify_signature.assert_called_once() + assert mock_verify_signature.call_args.kwargs["conversation_id"] == "conversation-1" + tool_file_manager_instance.create_file_by_raw.assert_called_once() + assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1" def test_missing_file(self): module.request = fake_request( diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py index 45f6ad31034..1b0a82ed7c5 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_plugin.py @@ -272,6 +272,7 @@ class TestPluginUploadFileRequestApi: mock_payload = MagicMock() mock_payload.filename = "test.pdf" mock_payload.mimetype = "application/pdf" + mock_payload.conversation_id = "conversation-id" # Act raw_post = _extract_raw_post(PluginUploadFileRequestApi) @@ -279,7 +280,11 @@ class TestPluginUploadFileRequestApi: # Assert mock_get_url.assert_called_once_with( - filename="test.pdf", mimetype="application/pdf", tenant_id="tenant-id", user_id="user-id" + filename="test.pdf", + mimetype="application/pdf", + tenant_id="tenant-id", + user_id="user-id", + conversation_id="conversation-id", ) assert result["data"]["url"] == "https://storage.example.com/signed-upload-url" diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index a1f6512625b..8ebf68c7061 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -14,6 +14,7 @@ import pytest from agenton.compositor import CompositorSessionSnapshot from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import ( + AgentRunUsage, CancelRunRequest, CancelRunResponse, PydanticAIStreamRunEvent, @@ -59,13 +60,6 @@ class _FakeCredentialsProvider: return {"openai_api_key": "sk-test"} -@pytest.fixture(autouse=True) -def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False - ) - - class _NoToolsBuilder: def build_layers(self, **kwargs): del kwargs @@ -75,12 +69,16 @@ class _NoToolsBuilder: class _FakeQueueManager: def __init__(self) -> None: self.events: list[Any] = [] + self._stop_requested = False def publish(self, event: Any, _from: Any) -> None: self.events.append(event) def is_stopped(self) -> bool: - return False + return self._stop_requested + + def request_stop(self) -> None: + self._stop_requested = True class _StoppedQueueManager(_FakeQueueManager): @@ -143,10 +141,65 @@ class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): data=RunSucceededEventData( output={"text": "hello agent"}, session_snapshot=CompositorSessionSnapshot(layers=[]), + usage=AgentRunUsage(prompt_tokens=3, completion_tokens=5), ), ) +class _StreamingRecordingFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient): + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + ) + yield PydanticAIStreamRunEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")), + ) + yield RunSucceededEvent( + id="4-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output={"text": "hello agent"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + + +class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgentBackendRunClient): + def __init__(self, *, queue_manager: _FakeQueueManager, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._queue_manager = queue_manager + + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + ) + self._queue_manager.request_stop() + yield PydanticAIStreamRunEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")), + ) + + class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient): @override def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: @@ -170,6 +223,46 @@ class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient): ) +class _NullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClient): + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield RunSucceededEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output=None, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + + +class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClient): + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="streamed answer")), + ) + yield RunSucceededEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output=None, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + + class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): @override def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: @@ -276,6 +369,19 @@ class _FakeSessionStore: ) +class _MonotonicClock: + def __init__(self, *values: float) -> None: + self._values = list(values) + self._index = 0 + + def __call__(self) -> float: + if self._index >= len(self._values): + return self._values[-1] + value = self._values[self._index] + self._index += 1 + return value + + def _soul() -> AgentSoulConfig: return AgentSoulConfig.model_validate( { @@ -299,7 +405,12 @@ def _dify_ctx() -> Any: ) -def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> AgentAppRunner: +def _runner( + client: FakeAgentBackendRunClient, + store: _FakeSessionStore, + *, + text_delta_debounce_seconds: float | None = 0, +) -> AgentAppRunner: return AgentAppRunner( request_builder=AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), @@ -308,6 +419,7 @@ def _runner(client: FakeAgentBackendRunClient, store: _FakeSessionStore) -> Agen agent_backend_client=client, event_adapter=AgentBackendRunEventAdapter(), session_store=store, # type: ignore[arg-type] + text_delta_debounce_seconds=text_delta_debounce_seconds, ) @@ -374,15 +486,10 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): assert saved_scope.agent_config_snapshot_id == "snap-1" assert saved_run_id == "fake-run-1" assert saved_snapshot is not None + assert saved_specs # A successful turn carries no ask_human pause correlation. assert pending_form_id is None assert pending_tool_call_id is None - assert [spec.name for spec in saved_specs] == [ - "agent_soul_prompt", - "agent_app_user_prompt", - "execution_context", - "history", - ] def test_successful_turn_forwards_agent_backend_stream_text_deltas_without_duplicate_terminal_chunk(): @@ -390,13 +497,16 @@ def test_successful_turn_forwards_agent_backend_stream_text_deltas_without_dupli store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, text_delta_debounce_seconds=0), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] assert [event.chunk.delta.message.content for event in chunk_events] == ["hello ", "agent"] assert len(end_events) == 1 assert end_events[0].llm_result.message.content == "hello agent" + assert end_events[0].llm_result.usage.prompt_tokens == 3 + assert end_events[0].llm_result.usage.completion_tokens == 5 + assert end_events[0].llm_result.usage.total_tokens == 8 assert store.saved @@ -405,7 +515,7 @@ def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, text_delta_debounce_seconds=0), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] @@ -414,6 +524,34 @@ def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal assert end_events[0].llm_result.message.content == "hello agent" +def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_literal_null(): + client = _NullOutputFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert chunk_events == [] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "" + + +def test_successful_turn_with_streamed_text_and_null_terminal_output_keeps_streamed_answer(): + client = _StreamingTextNullOutputFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store, text_delta_debounce_seconds=0), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["streamed answer"] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "streamed answer" + + def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): fake_session = _FakeDbSession() monkeypatch.setattr(app_runner_module.db, "session", fake_session) @@ -421,7 +559,7 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, text_delta_debounce_seconds=0), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"] @@ -436,6 +574,50 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): assert rows[1].observation == "ok" +def test_streaming_turn_batches_text_deltas_within_debounce_window(monkeypatch): + monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0, 0.2)) + client = _StreamingFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "hello agent" + + +def test_streaming_turn_flushes_pending_text_before_terminal_success(monkeypatch): + monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0)) + client = _StreamingPartStartFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello", " agent"] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "hello agent" + + +def test_streaming_turn_flushes_pending_text_before_stop_and_cancel(monkeypatch): + monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0)) + store = _FakeSessionStore() + qm = _FakeQueueManager() + client = _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(queue_manager=qm) + + with pytest.raises(GenerateTaskStoppedError): + _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello "] + assert client.cancelled_run_ids == ["fake-run-1"] + + def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypatch): fake_session = _FakeDbSession() monkeypatch.setattr(app_runner_module.db, "session", fake_session) @@ -611,6 +793,7 @@ def test_stopped_task_cancels_agent_backend_run_and_skips_session_save(): def test_extract_answer_handles_plain_string_and_dict(): + assert AgentAppRunner._extract_answer(None) == "" assert AgentAppRunner._extract_answer("plain text") == "plain text" assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi" assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}' diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 5267d1833e3..9828216f3b8 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -191,6 +191,8 @@ class TestAgentAppRuntimeRequestBuilder: "agent_soul_prompt", "agent_app_user_prompt", "execution_context", + DIFY_SHELL_LAYER_ID, + DIFY_CONFIG_LAYER_ID, "history", "llm", ] @@ -394,10 +396,7 @@ def _soul_with_model_and_skill() -> AgentSoulConfig: class TestAgentAppConfigLayer: - def test_config_layer_injected_when_flag_enabled(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) + def test_config_layer_injected(self): builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] @@ -423,10 +422,7 @@ class TestAgentAppConfigLayer: assert names.index(DIFY_SHELL_LAYER_ID) == names.index("execution_context") + 1 assert names.index(DIFY_CONFIG_LAYER_ID) == names.index(DIFY_SHELL_LAYER_ID) + 1 - def test_no_config_layer_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) + def test_config_layer_present_when_agent_soul_has_no_config_assets(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), @@ -436,14 +432,20 @@ class TestAgentAppConfigLayer: result = builder.build(_ctx(_soul_with_model())) layers = {layer.name: layer for layer in result.request.composition.layers} - assert DIFY_CONFIG_LAYER_ID not in layers + assert layers[DIFY_CONFIG_LAYER_ID].config.model_dump(mode="json") == { + "agent_id": "agent-1", + "config_version": {"id": "snap-1", "kind": "snapshot", "writable": False}, + "skills": [], + "files": [], + "env_keys": [], + "note": "", + "mentioned_skill_names": [], + "mentioned_file_names": [], + } assert layers[DIFY_SHELL_LAYER_ID].deps == {"execution_context": "execution_context"} assert layers[DIFY_SHELL_LAYER_ID].config.agent_stub_drive_ref is None - def test_config_layer_for_build_draft_marks_config_writable(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) + def test_config_layer_for_build_draft_marks_config_writable(self): builder = AgentAppRuntimeRequestBuilder( credentials_provider=_FakeCredentialsProvider(), dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] @@ -470,17 +472,6 @@ class TestAgentAppConfigLayer: "mentioned_file_names": [], } - def test_no_config_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False - ) - builder = AgentAppRuntimeRequestBuilder( - credentials_provider=_FakeCredentialsProvider(), - dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] - ) - result = builder.build(_ctx(_soul_with_model_and_skill())) - assert all(layer.name != DIFY_CONFIG_LAYER_ID for layer in result.request.composition.layers) - @pytest.mark.parametrize( ("system_prompt", "expected_prefix"), [ @@ -496,13 +487,9 @@ class TestAgentAppConfigLayer: ) def test_agent_app_runtime_expands_config_mentions_in_agent_soul_prompt( self, - monkeypatch: pytest.MonkeyPatch, system_prompt: str, expected_prefix: str, ): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) soul = _soul_with_model_and_skill() soul.prompt.system_prompt = system_prompt builder = AgentAppRuntimeRequestBuilder( @@ -518,11 +505,7 @@ class TestAgentAppConfigLayer: def test_agent_app_runtime_missing_config_mentions_fall_back_without_marker_leak( self, - monkeypatch: pytest.MonkeyPatch, ): - monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) soul = _soul_with_model() soul.prompt.system_prompt = ( "Use [§skill:ghost-skill:Ghost Skill§], [§file:ghost.txt:Ghost File§], and [§file:no-label.txt§]." @@ -537,3 +520,8 @@ class TestAgentAppConfigLayer: prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") assert prompt_layer.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt." assert "[§" not in prompt_layer.config.prefix + assert [warning["code"] for warning in result.metadata["runtime_support"]["unsupported_runtime_warnings"]] == [ + "mention_target_missing", + "mention_target_missing", + "mention_target_missing", + ] diff --git a/api/tests/unit_tests/core/helper/test_marketplace.py b/api/tests/unit_tests/core/helper/test_marketplace.py index bd561b16373..eba1e4e5442 100644 --- a/api/tests/unit_tests/core/helper/test_marketplace.py +++ b/api/tests/unit_tests/core/helper/test_marketplace.py @@ -14,22 +14,22 @@ from core.helper.marketplace import ( def test_get_plugin_pkg_url_contains_unique_identifier() -> None: - url = get_plugin_pkg_url("plugin@1.0.0") + url = get_plugin_pkg_url("langgenius/openai:0.4.2@checksum") assert "api/v1/plugins/download" in url - assert "unique_identifier=plugin@1.0.0" in url + assert "unique_identifier=langgenius%2Fopenai%3A0.4.2%40checksum" in url def test_download_plugin_pkg_delegates_with_configured_size(mocker: MockerFixture) -> None: mocked_download = mocker.patch("core.helper.marketplace.download_with_size_limit", return_value=b"pkg") mocker.patch("core.helper.marketplace.dify_config.PLUGIN_MAX_PACKAGE_SIZE", 1234) - result = download_plugin_pkg("plugin.a.b") + result = download_plugin_pkg("langgenius/openai:0.4.2@checksum") assert result == b"pkg" mocked_download.assert_called_once() called_url, called_limit = mocked_download.call_args.args - assert "unique_identifier=plugin.a.b" in called_url + assert "unique_identifier=langgenius%2Fopenai%3A0.4.2%40checksum" in called_url assert called_limit == 1234 diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index b53520f1eaf..b121fe261c2 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -95,6 +95,16 @@ def _metadata_condition() -> AppMetadataFilteringCondition: return AppMetadataFilteringCondition(logical_operator="and", conditions=[]) +@contextmanager +def _patched_retriever_session(): + session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = session + session_ctx.__exit__.return_value = None + with patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session", return_value=session_ctx): + yield session + + def create_side_effect_for_search(documents: list[Document]): """ Create a side effect function for mocking search methods. @@ -1682,7 +1692,15 @@ class TestRetrievalService: # Mock _retriever to return documents def side_effect_retriever( - flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids + flask_app, + session, + dataset_id, + query, + top_k, + all_documents, + document_ids_filter, + metadata_condition, + attachment_ids, ): all_documents.extend([doc1, doc2]) @@ -1694,23 +1712,24 @@ class TestRetrievalService: all_documents = [] # Act - Call with dataset_count = 1 - dataset_retrieval._multiple_retrieve_thread( - flask_app=mock_flask_app, - available_datasets=[mock_dataset], - metadata_condition=None, - metadata_filter_document_ids=None, - all_documents=all_documents, - tenant_id=tenant_id, - reranking_enable=True, - reranking_mode="reranking_model", - reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, - weights=None, - top_k=5, - score_threshold=0.5, - query="test query", - attachment_id=None, - dataset_count=1, # Single dataset - should skip second reranking - ) + with _patched_retriever_session(): + dataset_retrieval._multiple_retrieve_thread( + flask_app=mock_flask_app, + available_datasets=[mock_dataset], + metadata_condition=None, + metadata_filter_document_ids=None, + all_documents=all_documents, + tenant_id=tenant_id, + reranking_enable=True, + reranking_mode="reranking_model", + reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + weights=None, + top_k=5, + score_threshold=0.5, + query="test query", + attachment_id=None, + dataset_count=1, # Single dataset - should skip second reranking + ) # Assert # DataPostProcessor should NOT be called (second reranking skipped) @@ -1757,7 +1776,15 @@ class TestRetrievalService: # Mock _retriever to return documents def side_effect_retriever( - flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids + flask_app, + session, + dataset_id, + query, + top_k, + all_documents, + document_ids_filter, + metadata_condition, + attachment_ids, ): all_documents.extend([doc1, doc2]) @@ -1793,23 +1820,24 @@ class TestRetrievalService: mock_dataset2.provider = "dify" # Act - Call with dataset_count = 2 - dataset_retrieval._multiple_retrieve_thread( - flask_app=mock_flask_app, - available_datasets=[mock_dataset, mock_dataset2], - metadata_condition=None, - metadata_filter_document_ids=None, - all_documents=all_documents, - tenant_id=tenant_id, - reranking_enable=True, - reranking_mode="reranking_model", - reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, - weights=None, - top_k=5, - score_threshold=0.5, - query="test query", - attachment_id=None, - dataset_count=2, # Multiple datasets - should perform second reranking - ) + with _patched_retriever_session(): + dataset_retrieval._multiple_retrieve_thread( + flask_app=mock_flask_app, + available_datasets=[mock_dataset, mock_dataset2], + metadata_condition=None, + metadata_filter_document_ids=None, + all_documents=all_documents, + tenant_id=tenant_id, + reranking_enable=True, + reranking_mode="reranking_model", + reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + weights=None, + top_k=5, + score_threshold=0.5, + query="test query", + attachment_id=None, + dataset_count=2, # Multiple datasets - should perform second reranking + ) # Assert # DataPostProcessor SHOULD be called (second reranking performed) @@ -1867,7 +1895,15 @@ class TestRetrievalService: # Mock _retriever to return documents def side_effect_retriever( - flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids + flask_app, + session, + dataset_id, + query, + top_k, + all_documents, + document_ids_filter, + metadata_condition, + attachment_ids, ): all_documents.extend([doc1, doc2]) @@ -1889,23 +1925,24 @@ class TestRetrievalService: all_documents = [] # Act - Call with dataset_count = 1 - dataset_retrieval._multiple_retrieve_thread( - flask_app=mock_flask_app, - available_datasets=[mock_dataset], - metadata_condition=None, - metadata_filter_document_ids=None, - all_documents=all_documents, - tenant_id=tenant_id, - reranking_enable=True, # Reranking enabled but should be skipped for single dataset - reranking_mode="reranking_model", - reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, - weights=None, - top_k=5, - score_threshold=0.5, - query="test query", - attachment_id=None, - dataset_count=1, - ) + with _patched_retriever_session(): + dataset_retrieval._multiple_retrieve_thread( + flask_app=mock_flask_app, + available_datasets=[mock_dataset], + metadata_condition=None, + metadata_filter_document_ids=None, + all_documents=all_documents, + tenant_id=tenant_id, + reranking_enable=True, # Reranking enabled but should be skipped for single dataset + reranking_mode="reranking_model", + reranking_model={"reranking_provider_name": "cohere", "reranking_model_name": "rerank-v2"}, + weights=None, + top_k=5, + score_threshold=0.5, + query="test query", + attachment_id=None, + dataset_count=1, + ) # Assert # DataPostProcessor should NOT be called @@ -3720,7 +3757,15 @@ class TestKnowledgeRetrievalRegression: ) def fake_retriever( - flask_app, dataset_id, query, top_k, all_documents, document_ids_filter, metadata_condition, attachment_ids + flask_app, + session, + dataset_id, + query, + top_k, + all_documents, + document_ids_filter, + metadata_condition, + attachment_ids, ): all_documents.append(document) @@ -3744,32 +3789,35 @@ class TestKnowledgeRetrievalRegression: thread_exceptions: list[Exception] = [] def target(): - with patch.object(dataset_retrieval, "_retriever", side_effect=fake_retriever): - with patch( + with ( + patch.object(dataset_retrieval, "_retriever", side_effect=fake_retriever), + patch( "core.rag.retrieval.dataset_retrieval.DataPostProcessor", ContextRequiredPostProcessor, - ): - dataset_retrieval._multiple_retrieve_thread( - flask_app=flask_app, - available_datasets=[mock_dataset, secondary_dataset], - metadata_condition=None, - metadata_filter_document_ids=None, - all_documents=all_documents, - tenant_id=tenant_id, - reranking_enable=True, - reranking_mode="reranking_model", - reranking_model={ - "reranking_provider_name": "cohere", - "reranking_model_name": "rerank-v2", - }, - weights=None, - top_k=3, - score_threshold=0.0, - query="test query", - attachment_id=None, - dataset_count=2, # force reranking branch - thread_exceptions=thread_exceptions, # ✅ key - ) + ), + _patched_retriever_session(), + ): + dataset_retrieval._multiple_retrieve_thread( + flask_app=flask_app, + available_datasets=[mock_dataset, secondary_dataset], + metadata_condition=None, + metadata_filter_document_ids=None, + all_documents=all_documents, + tenant_id=tenant_id, + reranking_enable=True, + reranking_mode="reranking_model", + reranking_model={ + "reranking_provider_name": "cohere", + "reranking_model_name": "rerank-v2", + }, + weights=None, + top_k=3, + score_threshold=0.0, + query="test query", + attachment_id=None, + dataset_count=2, # force reranking branch + thread_exceptions=thread_exceptions, + ) t = threading.Thread(target=target) t.start() @@ -3781,6 +3829,53 @@ class TestKnowledgeRetrievalRegression: # Current buggy code should record an exception (not raise it) assert not thread_exceptions, thread_exceptions + def test_run_retriever_thread_provides_session_to_retriever(self): + dataset_retrieval = DatasetRetrieval() + all_documents: list[Document] = [] + + with _patched_retriever_session() as session: + with patch.object(dataset_retrieval, "_retriever") as mock_retriever: + dataset_retrieval._run_retriever_thread( + flask_app=_FakeFlaskApp(), + dataset_id="dataset-1", + query="test query", + top_k=3, + all_documents=all_documents, + document_ids_filter=None, + metadata_condition=None, + attachment_ids=None, + cancel_event=None, + thread_exceptions=[], + ) + + mock_retriever.assert_called_once() + assert mock_retriever.call_args.kwargs["session"] is session + + def test_run_retriever_thread_records_retriever_exception(self): + dataset_retrieval = DatasetRetrieval() + all_documents: list[Document] = [] + cancel_event = threading.Event() + thread_exceptions: list[Exception] = [] + expected_error = RuntimeError("retrieval failed") + + with _patched_retriever_session(): + with patch.object(dataset_retrieval, "_retriever", side_effect=expected_error): + dataset_retrieval._run_retriever_thread( + flask_app=_FakeFlaskApp(), + dataset_id="dataset-1", + query="test query", + top_k=3, + all_documents=all_documents, + document_ids_filter=None, + metadata_condition=None, + attachment_ids=None, + cancel_event=cancel_event, + thread_exceptions=thread_exceptions, + ) + + assert cancel_event.is_set() + assert thread_exceptions == [expected_error] + class _FakeFlaskApp: def app_context(self): diff --git a/api/tests/unit_tests/core/tools/test_signature.py b/api/tests/unit_tests/core/tools/test_signature.py index a75fdee9085..ee80cedd499 100644 --- a/api/tests/unit_tests/core/tools/test_signature.py +++ b/api/tests/unit_tests/core/tools/test_signature.py @@ -138,6 +138,7 @@ def test_get_signed_file_url_for_plugin_and_verify_roundtrip(monkeypatch: pytest mimetype="application/pdf", tenant_id="tenant-id", user_id="user-id", + conversation_id="conversation-id", ) parsed = urlparse(url) query = parse_qs(parsed.query) @@ -146,12 +147,14 @@ def test_get_signed_file_url_for_plugin_and_verify_roundtrip(monkeypatch: pytest assert parsed.path == "/files/upload/for-plugin" assert query["tenant_id"] == ["tenant-id"] assert query["user_id"] == ["user-id"] + assert query["conversation_id"] == ["conversation-id"] assert ( verify_plugin_file_signature( filename="report.pdf", mimetype="application/pdf", tenant_id="tenant-id", user_id="user-id", + conversation_id="conversation-id", timestamp=query["timestamp"][0], nonce=query["nonce"][0], sign=query["sign"][0], diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index f24f02a130f..99448f7f5a9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -2,7 +2,6 @@ from types import SimpleNamespace from typing import cast from unittest.mock import MagicMock, patch -import pytest from agenton.compositor import CompositorSessionSnapshot from dify_agent.layers.ask_human import AskHumanToolResult from dify_agent.protocol import RunStartedEvent, RunSucceededEvent, RunSucceededEventData @@ -51,13 +50,6 @@ class FakeCredentialsProvider: return {"api_key": "secret-key"} -@pytest.fixture(autouse=True) -def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False - ) - - def _restored_file(*, transfer_method: FileTransferMethod, reference: str) -> File: return File( type=FileType.DOCUMENT, @@ -281,7 +273,8 @@ def test_agent_node_run_maps_successful_agent_backend_run_to_node_result(): assert agent_log["agent_backend"]["run_id"] == "fake-run-1" assert agent_log["agent_backend"]["status"] == "succeeded" assert result.process_data["agent_id"] == "agent-1" - assert result.inputs["agent_backend_request"]["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]" + layers = {layer["name"]: layer for layer in result.inputs["agent_backend_request"]["composition"]["layers"]} + assert layers["llm"]["config"]["credentials"] == "[REDACTED]" def test_agent_node_run_normalizes_declared_file_output_with_canonical_mapping(): diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py index e9af5e7707a..9a040926edf 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py @@ -148,6 +148,31 @@ def _file_tool() -> FakeTool: return FakeTool(entity=entity, runtime=runtime) +def _files_tool() -> FakeTool: + parameters = [ + ToolParameter( + name="documents", + label=I18nObject(en_US="Documents"), + type=ToolParameter.ToolParameterType.FILES, + form=ToolParameter.ToolParameterForm.LLM, + required=True, + llm_description="The documents to inspect.", + ) + ] + entity = ToolEntity( + identity=ToolIdentity( + author="langgenius", + name="inspect", + label=I18nObject(en_US="Inspect"), + provider="documents", + ), + description=ToolDescription(human=I18nObject(en_US="Inspect"), llm="Inspect documents."), + parameters=parameters, + ) + runtime = ToolRuntime(tenant_id="tenant-1", user_id="user-1", credentials={}, runtime_parameters={}) + return FakeTool(entity=entity, runtime=runtime) + + def _tts_tool() -> FakeTool: parameters = [ ToolParameter( @@ -274,6 +299,103 @@ def test_builds_core_tool_with_file_llm_parameter(): assert runtime_provider.last_use_default_for_missing_form_parameters is True +def test_builds_plugin_tool_with_file_llm_parameter_schema(): + runtime_provider = FakeRuntimeProvider(_file_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "langgenius/audio/audio", + "provider_type": "plugin", + "tool_name": "asr", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + schema = result.tools[0].parameters_json_schema + file_schema = schema["properties"]["audio_file"] + assert file_schema["anyOf"][0]["type"] == "string" + assert file_schema["anyOf"][1]["properties"]["transfer_method"]["enum"] == ["remote_url"] + assert file_schema["anyOf"][2]["properties"]["transfer_method"]["enum"] == [ + "local_file", + "tool_file", + "datasource_file", + ] + assert schema["required"] == ["audio_file"] + + +def test_builds_plugin_tool_with_files_llm_parameter_schema(): + runtime_provider = FakeRuntimeProvider(_files_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "langgenius/documents/documents", + "provider_type": "plugin", + "tool_name": "inspect", + "credential_type": "unauthorized", + } + ] + } + ) + + result = _build(builder, tools) + + assert result is not None + schema = result.tools[0].parameters_json_schema + files_schema = schema["properties"]["documents"] + assert files_schema["type"] == "array" + assert files_schema["items"]["anyOf"][0]["description"] == "HTTP(S) URL or sandbox-local file path." + assert schema["required"] == ["documents"] + + +def test_builds_builtin_compat_plugin_tool_with_files_llm_parameter_schema(): + runtime_provider = FakeRuntimeProvider(_files_tool()) + builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + tools = AgentSoulToolsConfig.model_validate( + { + "dify_tools": [ + { + "provider_id": "langgenius/dify-gmail/dify-gmail", + "provider_type": "builtin", + "provider": "langgenius/dify-gmail/dify-gmail", + "tool_name": "add_attachment_to_draft", + "credential_type": "api-key", + "credential_id": "credential-1", + } + ] + } + ) + + result = builder.build_layers( + tenant_id="tenant-1", + app_id="app-1", + user_id="user-1", + tools=tools, + invoke_from=InvokeFrom.DEBUGGER, + ) + + assert result.plugin_tools is not None + assert result.core_tools is None + prepared = result.plugin_tools.tools[0] + assert prepared.plugin_id == "langgenius/dify-gmail" + assert prepared.provider == "dify-gmail" + assert prepared.tool_name == "add_attachment_to_draft" + files_schema = prepared.parameters_json_schema["properties"]["documents"] + assert files_schema["type"] == "array" + assert files_schema["items"]["anyOf"][0]["description"] == "HTTP(S) URL or sandbox-local file path." + assert prepared.parameters_json_schema["required"] == ["documents"] + assert runtime_provider.last_agent_tool is not None + assert runtime_provider.last_agent_tool.provider_type.value == "builtin" + + def test_build_layers_routes_plugin_direct_and_builtin_via_core() -> None: runtime_provider = FakeRuntimeProvider(_tool()) builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 3ed5a688c8b..10f321d3a2a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -47,13 +47,6 @@ class FakeCredentialsProvider: return {"api_key": "secret-key"} -@pytest.fixture(autouse=True) -def _disable_drive_manifest_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False - ) - - class CapturingCredentialsProvider: def __init__(self) -> None: self.provider_name: str | None = None @@ -491,7 +484,8 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): "secret_refs": [ {"variable": "TOKEN", "credential_id": "credential-1"}, {"name": "API_KEY", "provider_credential_id": "credential-2"}, - {"name": "EDITABLE_TOKEN", "value": "credential-3"}, + {"name": "EDITABLE_TOKEN", "value": "inline-secret-value"}, + {"name": "LEGACY_SECRET_REF", "id": "credential-3"}, {"ref": "missing-name"}, ], }, @@ -508,11 +502,12 @@ def test_build_shell_layer_config_accepts_legacy_fallback_keys(): assert config["env"] == [ {"name": "PROJECT_NAME", "value": "demo"}, {"name": "RETRY_COUNT", "value": "3"}, + {"name": "EDITABLE_TOKEN", "value": "inline-secret-value"}, ] assert config["secret_refs"] == [ {"name": "TOKEN", "ref": "credential-1"}, {"name": "API_KEY", "ref": "credential-2"}, - {"name": "EDITABLE_TOKEN", "ref": "credential-3"}, + {"name": "LEGACY_SECRET_REF", "ref": "credential-3"}, ] assert config["sandbox"] is None @@ -613,7 +608,37 @@ def test_build_shell_layer_config_maps_cli_tool_scoped_env(): ] -def test_builds_workflow_run_request_with_dify_plugin_tools_layer(): +def test_build_shell_layer_config_maps_cli_tool_inline_secret_value_to_env(): + agent_soul = AgentSoulConfig.model_validate( + { + "tools": { + "cli_tools": [ + { + "name": "github", + "command": "apt-get install -y gh", + "env": { + "secret_refs": [{"name": "GITHUB_TOKEN", "value": "ghp_" + "x" * 300}], + }, + } + ] + } + } + ) + + config = build_shell_layer_config(agent_soul).model_dump(mode="json") + + assert config["cli_tools"] == [ + { + "name": "github", + "install_commands": ["apt-get install -y gh"], + "env": [{"name": "GITHUB_TOKEN", "value": "ghp_" + "x" * 300}], + "secret_refs": [], + } + ] + + +def test_builds_workflow_run_request_with_dify_plugin_tools_layer(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -649,7 +674,10 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer(): dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["type"] == "dify.plugin.tools" - assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} + assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["deps"] == { + "execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID, + "shell": DIFY_SHELL_LAYER_ID, + } assert layers[DIFY_PLUGIN_TOOLS_LAYER_ID]["config"]["tools"][0]["tool_name"] == "current_time" assert result.metadata["agent_tools"] == { "dify_tool_count": 1, @@ -1300,7 +1328,7 @@ def test_build_config_layer_config_includes_soul_context_and_mentions(): assert warnings == [] -def test_build_config_layer_config_returns_none_for_empty_agent_soul(): +def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul(): from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config soul = AgentSoulConfig( @@ -1308,30 +1336,43 @@ def test_build_config_layer_config_returns_none_for_empty_agent_soul(): ) config, warnings = build_config_layer_config(soul) - assert config is None + assert config is not None + assert config.model_dump(mode="json") == { + "agent_id": None, + "config_version": {"id": None, "kind": "snapshot", "writable": False}, + "skills": [], + "files": [], + "env_keys": [], + "note": "", + "mentioned_skill_names": [], + "mentioned_file_names": [], + } assert warnings == [] -def test_workflow_run_request_has_no_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) +def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True) result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context()) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} - assert DIFY_CONFIG_LAYER_ID not in layers + assert layers[DIFY_CONFIG_LAYER_ID]["config"] == { + "agent_id": "agent-1", + "config_version": {"id": "snapshot-1", "kind": "snapshot", "writable": False}, + "skills": [], + "files": [], + "env_keys": [], + "note": "", + "mentioned_skill_names": [], + "mentioned_file_names": [], + } assert layers[DIFY_SHELL_LAYER_ID]["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID} assert layers[DIFY_SHELL_LAYER_ID]["config"]["agent_stub_drive_ref"] is None -def test_workflow_run_request_contains_config_layer_when_flag_enabled(monkeypatch: pytest.MonkeyPatch): +def test_workflow_run_request_contains_config_layer(): """Contract test: locks the dify.config composition shape against cross-package drift.""" - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) context = _context() context.snapshot.config_snapshot = _soul_with_config_assets() @@ -1372,10 +1413,7 @@ def test_workflow_run_request_contains_config_layer_when_flag_enabled(monkeypatc assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs) -def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) +def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(): context = _context() context.snapshot.config_snapshot = _soul_with_config_assets() @@ -1386,10 +1424,7 @@ def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(monkeypat assert "[§" not in soul_prompt.config.prefix -def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True - ) +def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(): context = _context() context.snapshot.config_snapshot = AgentSoulConfig( prompt={ @@ -1405,20 +1440,11 @@ def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(m soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt." assert "[§" not in soul_prompt.config.prefix - - -def test_workflow_run_request_has_no_config_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False - ) - context = _context() - context.snapshot.config_snapshot = _soul_with_config_assets() - - result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) - - dumped = result.request.model_dump(mode="json") - assert all(layer["name"] != DIFY_CONFIG_LAYER_ID for layer in dumped["composition"]["layers"]) - assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == [] + assert [warning["code"] for warning in result.metadata["runtime_support"]["unsupported_runtime_warnings"]] == [ + "mention_target_missing", + "mention_target_missing", + "mention_target_missing", + ] def test_build_config_layer_config_missing_mentions_warn_without_catalog(): diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index 43323ca49b4..07f993c1b8e 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -124,6 +124,38 @@ def test_agent_app_soul_allows_app_features_and_variables(): assert payload.agent_soul.app_variables[0].name == "company_name" +def test_agent_app_soul_accepts_legacy_follow_up_model_config(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + "agent_soul": { + "app_features": { + "suggested_questions_after_answer": { + "enabled": True, + "prompt": "Suggest useful follow-up questions.", + "model": { + "provider": "openai", + "name": "gpt-4o-mini", + "mode": "chat", + "completion_params": {"temperature": 0.7, "max_tokens": 128}, + }, + }, + }, + }, + } + ) + + assert payload.agent_soul is not None + follow_up = payload.agent_soul.app_features.suggested_questions_after_answer + assert follow_up is not None + assert follow_up.enabled is True + assert follow_up.model is not None + assert follow_up.model.provider == "openai" + assert follow_up.model.name == "gpt-4o-mini" + assert follow_up.model.completion_params == {"temperature": 0.7, "max_tokens": 128} + + def test_composer_save_payload_accepts_new_roster_metadata(): payload = ComposerSavePayload.model_validate( { diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py index f98c548d176..1b735a18a62 100644 --- a/api/tests/unit_tests/services/agent/test_agent_observability_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -50,6 +50,23 @@ def test_resolve_source_filters_accepts_multiple_structured_sources() -> None: assert AgentObservabilityService.resolve_source_filters(("all", "webapp:app-1"))[0].kind == "all" +def test_statistics_all_source_includes_debugger_messages() -> None: + source_filter = AgentObservabilityService.resolve_source_filter("all") + + scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter) + + assert "m.app_id = :app_id" in scope_sql + assert "m.invoke_from != :debugger" not in scope_sql + + +def test_statistics_explicit_source_filters_invoke_from() -> None: + source_filter = AgentObservabilityService.resolve_source_filter("debugger") + + scope_sql = AgentObservabilityService._statistics_message_scope_sql(source_filter) + + assert "m.invoke_from = :source" in scope_sql + + def test_apply_status_filter_accepts_multiple_statuses() -> None: class FakeStmt: def __init__(self): 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 2ff68108c81..cebbbf4f94f 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -2826,11 +2826,12 @@ def test_composer_validator_rejects_unauthorized_secret_and_cli_tool(): def test_composer_validator_accepts_valid_shell_env_and_cli(): """Valid shell identifiers + a disabled empty CLI tool pass validation.""" + long_secret = "sk-" + "x" * 512 config = ComposerConfigValidator.validate_agent_soul_dict( { "env": { "variables": [{"name": "MY_VAR", "value": "v"}], - "secret_refs": [{"name": "API_TOKEN", "value": "credential-1"}], + "secret_refs": [{"name": "API_TOKEN", "value": long_secret}], }, "tools": { "cli_tools": [ @@ -2855,7 +2856,7 @@ def test_composer_validator_accepts_valid_shell_env_and_cli(): ) assert {variable.name for variable in config.env.variables} == {"MY_VAR"} assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"} - assert config.env.secret_refs[0].value == "credential-1" + assert config.env.secret_refs[0].value == long_secret assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR" assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN" assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2" 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 bd8d84834fb..a8922154a95 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -8,6 +8,7 @@ import zstandard from pydantic import TypeAdapter from redis import RedisError +from core.plugin.entities.plugin import 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 @@ -888,9 +889,27 @@ class TestPluginModelProviderCacheInvalidation: from core.plugin.plugin_service import PluginService - result = PluginService.install_from_local_pkg("tenant-1", ["langgenius/openai:1.0.0"]) + result = PluginService.install_from_local_pkg( + "tenant-1", + [ + "langgenius/openai:1.0.0", + "langgenius/tavily:1.0.0", + ], + ) assert result == "task-id" + installer.install_from_identifiers.assert_called_once_with( + "tenant-1", + [ + "langgenius/openai:1.0.0", + "langgenius/tavily:1.0.0", + ], + PluginInstallationSource.Package, + [ + {"plugin_unique_identifier": "langgenius/openai:1.0.0"}, + {"plugin_unique_identifier": "langgenius/tavily:1.0.0"}, + ], + ) invalidate_cache.assert_called_once_with("tenant-1") def test_upgrade_plugin_with_github_invalidates_model_provider_cache_for_tenant(self) -> None: diff --git a/api/tests/unit_tests/services/test_agent_config_service.py b/api/tests/unit_tests/services/test_agent_config_service.py index 57b55f0a49f..f9a649a13ab 100644 --- a/api/tests/unit_tests/services/test_agent_config_service.py +++ b/api/tests/unit_tests/services/test_agent_config_service.py @@ -342,6 +342,38 @@ def test_push_file_for_console_uses_service_owned_upload_lookup_and_naming() -> session.commit.assert_called_once() +def test_upload_skill_for_console_maps_package_validation_failures() -> None: + session = MagicMock() + service = AgentConfigService() + target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False) + message = "skill package must contain exactly one skill; multiple skill folders in one archive are not supported" + + with ( + patch(f"{MODULE}.session_factory.create_session", return_value=_session_cm(session)), + patch.object(service, "_resolve_target_in_session", return_value=target), + patch.object( + service._skill_normalizer, + "normalize", + side_effect=SkillPackageError("files_outside_skill_root", message, status_code=400), + ), + ): + with pytest.raises(AgentConfigServiceError, match="exactly one skill") as exc_info: + service.upload_skill_for_console( + tenant_id=TENANT, + agent_id=AGENT, + user_id=USER, + config_version_id="draft-1", + config_version_kind=AgentConfigVersionKind.DRAFT, + content=b"bad-archive", + filename="skills.zip", + ) + + assert exc_info.value.code == "files_outside_skill_root" + assert exc_info.value.message == message + assert exc_info.value.status_code == 400 + session.commit.assert_not_called() + + def test_apply_skill_updates_rejects_non_tool_file_refs() -> None: service = AgentConfigService() diff --git a/api/tests/unit_tests/services/test_file_request_service.py b/api/tests/unit_tests/services/test_file_request_service.py index 3bab033dfec..f5d1a59c17e 100644 --- a/api/tests/unit_tests/services/test_file_request_service.py +++ b/api/tests/unit_tests/services/test_file_request_service.py @@ -5,7 +5,6 @@ import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.file_access import FileAccessScope -from core.workflow.file_reference import build_file_reference from services.file_request_service import FileRequestService @@ -25,7 +24,7 @@ def test_request_download_url_builds_file_under_bound_scope( fake_file = MagicMock(filename="report.pdf", mime_type="application/pdf", size=123) access_controller = MagicMock() service = FileRequestService(access_controller=access_controller) - reference = build_file_reference(record_id="tool-file-1") + reference = "dify-file-ref:tool-file-1" with ( patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()) as bind_scope, diff --git a/api/uv.lock b/api/uv.lock index 6e7e1811064..30df243450a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1306,7 +1306,7 @@ requires-dist = [ { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" }, { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" }, { name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" }, - { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.3.0" }, + { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.4.0" }, { name = "typer", specifier = ">=0.16.1,<0.17" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" }, diff --git a/dify-agent/docker/local-sandbox/Dockerfile b/dify-agent/docker/local-sandbox/Dockerfile index 955ed68a599..382e43fb0a6 100644 --- a/dify-agent/docker/local-sandbox/Dockerfile +++ b/dify-agent/docker/local-sandbox/Dockerfile @@ -4,7 +4,7 @@ # docker build -f docker/local-sandbox/Dockerfile -t dify-agent-local-sandbox:local . # # This image merges the former shellctl-only image with the sandbox-visible -# Agent Stub client CLI. It runs shellctl by default, and shellctl-managed jobs +# Agent Stub client CLI. It runs `shellctl serve` by default, and shellctl-managed jobs # can call `dify-agent ...` without installing extra packages at runtime. FROM python:3.12-slim-bookworm AS base @@ -13,7 +13,7 @@ ARG NODE_VERSION=22.22.1 ARG PNPM_VERSION=11.9.0 ARG UV_VERSION=0.8.9 ARG DIFY_AGENT_TOOL_SPEC=.[grpc] -ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager +ARG SHELL_SESSION_MANAGER_TOOL_SPEC=shell-session-manager==2.4.0 ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ @@ -83,6 +83,7 @@ COPY --from=tools ${UV_TOOL_BIN_DIR} ${UV_TOOL_BIN_DIR} RUN useradd --create-home --shell /bin/sh dify \ && mkdir -p /mnt/drive \ + && chown dify:dify /home \ && chown -R dify:dify /home/dify /mnt/drive USER dify diff --git a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md index a915031fe1d..23efec73205 100644 --- a/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md +++ b/dify-agent/docs/dify-agent/user-manual/shell-layer/index.md @@ -230,7 +230,7 @@ The provided `docker/local-sandbox/Dockerfile` installs: - `tmux`, required by `shellctl` to manage shell jobs; - common shell workspace tools: `git`, `openssh-client`, `jq`, `ripgrep`, `unzip`, `zip`, `file`, `procps`, and `less`; -- `shell-session-manager==2.3.0` as a standalone uv tool, which provides the +- `shell-session-manager==2.3.1` as a standalone uv tool, which provides the `shellctl` CLI/server; - `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell workspace and Python CLI tools can be installed with isolated tool diff --git a/dify-agent/pyproject.toml b/dify-agent/pyproject.toml index 1c8842dccc5..5bc84091a24 100644 --- a/dify-agent/pyproject.toml +++ b/dify-agent/pyproject.toml @@ -27,7 +27,7 @@ server = [ "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", "pydantic-settings>=2.12.0,<3.0.0", "redis>=7.4.0,<8.0.0", - "shell-session-manager==2.3.0", + "shell-session-manager==2.4.0", "uvicorn[standard]==0.46.0", ] diff --git a/dify-agent/src/dify_agent/adapters/llm/model.py b/dify-agent/src/dify_agent/adapters/llm/model.py index 1863dec1388..26982cc2c29 100644 --- a/dify-agent/src/dify_agent/adapters/llm/model.py +++ b/dify-agent/src/dify_agent/adapters/llm/model.py @@ -565,13 +565,7 @@ def _chunk_to_stream_events( elif isinstance(message.content, list): for part in _map_assistant_content_to_response_parts(message.content): if isinstance(part, TextPart): - events.extend( - parts_manager.handle_text_delta( - vendor_part_id=None, - content=part.content, - provider_name=provider_name, - ) - ) + events.extend(embedded_thinking_parser.parse(parts_manager, part.content, provider_name)) else: events.append(parts_manager.handle_part(vendor_part_id=None, part=part)) diff --git a/dify-agent/src/dify_agent/adapters/shell/shellctl.py b/dify-agent/src/dify_agent/adapters/shell/shellctl.py index 31f2e13675f..6cf334245fb 100644 --- a/dify-agent/src/dify_agent/adapters/shell/shellctl.py +++ b/dify-agent/src/dify_agent/adapters/shell/shellctl.py @@ -1,3 +1,11 @@ +"""Shellctl-backed shell provider adapter for dify-agent. + +The shell-session-manager SDK owns the HTTP timeout policy for long-polling +shellctl requests. This adapter stays narrowly focused on translating SDK and +transport failures into ``ShellProviderError`` so the shell layer can return +tool observations instead of aborting the agent loop. +""" + from __future__ import annotations import base64 @@ -10,6 +18,8 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Protocol, TypeVar +import httpx + from dify_agent.adapters.shell.protocols import ( ShellCommandProtocol, ShellCommandResult, @@ -168,11 +178,11 @@ class ShellctlCommands(ShellCommandProtocol): grace_seconds: float | None = None, ) -> None: try: - _ = await self.client.delete(job_id, force=force, grace_seconds=grace_seconds) - except RuntimeError as exc: - if getattr(exc, "code", None) == "job_not_found": + _ = await _run_client_call(self.client.delete(job_id, force=force, grace_seconds=grace_seconds)) + except ShellProviderError as exc: + if exc.code == "job_not_found": return - raise _map_error(exc) from exc + raise @dataclass(slots=True) @@ -274,8 +284,14 @@ class _CompletedShellctlJob: async def _run_client_call(awaitable: Awaitable[ResultT]) -> ResultT: + """Map shellctl client boundary failures into provider-layer errors.""" + try: return await awaitable + except httpx.TimeoutException as exc: + raise ShellProviderError(str(exc), code="timeout") from exc + except httpx.RequestError as exc: + raise ShellProviderError(str(exc), code="request_error") from exc except RuntimeError as exc: raise _map_error(exc) from exc @@ -338,7 +354,7 @@ async def _run_to_completion( finally: if job_id is not None: try: - await client.delete(job_id, force=True) + await _run_client_call(client.delete(job_id, force=True)) except RuntimeError as exc: logger.warning("Failed to delete shellctl job %s: %s", job_id, exc) diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_config.py b/dify-agent/src/dify_agent/agent_stub/cli/_config.py index 0aabc9b1e25..7d20354ac7d 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_config.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_config.py @@ -2,9 +2,7 @@ from __future__ import annotations -import json import os -import re from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory @@ -36,7 +34,6 @@ from dify_agent.agent_stub.protocol.agent_stub import ( _DEFAULT_CONFIG_BASE = Path("./.dify_conf") _SKILL_MD_FILENAME = "SKILL.md" -_SAFE_ENV_VALUE = re.compile(r"^[A-Za-z0-9_./:@+-]+$") class ConfigSkillPullResult(BaseModel): @@ -141,19 +138,6 @@ def pull_config_files_from_environment( return ConfigFilePullResult(items=items) -def pull_config_env_from_environment(local_path: str | None = None) -> Path: - manifest = manifest_from_environment() - target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / ".env")).expanduser().resolve() - target_path.parent.mkdir(parents=True, exist_ok=True) - lines: list[str] = [] - for key in manifest.env_keys: - if key not in os.environ: - raise AgentStubValidationError(f"config env key is not present in the current environment: {key}") - lines.append(f"{key}={_format_env_value(os.environ[key])}") - target_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8") - return target_path - - def pull_config_note_from_environment(local_path: str | None = None) -> Path: manifest = manifest_from_environment() target_path = Path(local_path or (_DEFAULT_CONFIG_BASE / "note.md")).expanduser().resolve() @@ -167,22 +151,19 @@ def push_config_note_from_environment(local_path: str | None) -> AgentStubConfig return _push_config_from_environment(note=note) -def push_config_env_from_environment(local_path: str | None) -> AgentStubConfigManifestResponse: - env_text = _read_text_input(local_path, _DEFAULT_CONFIG_BASE / ".env") +def push_config_env_from_environment(local_path: str) -> AgentStubConfigManifestResponse: + env_text = _read_text_input(local_path, default_path=None) return _push_config_from_environment(env_text=env_text) -def push_config_files_from_environment(paths: list[str], name: str | None) -> AgentStubConfigManifestResponse: +def push_config_files_from_environment(paths: list[str]) -> AgentStubConfigManifestResponse: _require_non_empty_inputs(paths, kind="file path") - override_name = None if name is None else _require_config_entry_name(name, kind="file") - if override_name is not None and len(paths) != 1: - raise AgentStubValidationError("--name requires exactly one PATH") items = [ _PreparedPushItem( - name=override_name if index == 0 and override_name is not None else _infer_name_from_path(source_path), + name=_infer_name_from_path(source_path), path=source_path, ) - for index, source_path in enumerate(_resolve_input_paths(paths)) + for source_path in _resolve_input_paths(paths) ] return _push_config_from_environment(files=[_build_file_push_item(item=item) for item in items]) @@ -236,10 +217,15 @@ def _push_config_from_environment( ) -def _read_text_input(path_value: str | None, default_path: Path) -> str: +def _read_text_input(path_value: str | None, default_path: Path | None) -> str: if path_value == "-": return os.fdopen(os.dup(0), encoding="utf-8").read() - source_path = Path(path_value or default_path).expanduser().resolve() + if path_value is None: + if default_path is None: + raise AgentStubValidationError("local file path or '-' is required") + source_path = default_path.expanduser().resolve() + else: + source_path = Path(path_value).expanduser().resolve() if not source_path.is_file(): raise AgentStubValidationError(f"local file not found: {source_path}") return source_path.read_text(encoding="utf-8") @@ -297,17 +283,10 @@ def _require_non_empty_inputs(values: list[str], *, kind: str) -> None: raise AgentStubValidationError(f"at least one {kind} is required") -def _format_env_value(value: str) -> str: - if _SAFE_ENV_VALUE.fullmatch(value): - return value - return json.dumps(value) - - __all__ = [ "ConfigFilePullResult", "ConfigSkillPullResult", "manifest_from_environment", - "pull_config_env_from_environment", "pull_config_files_from_environment", "pull_config_note_from_environment", "pull_config_skills_from_environment", diff --git a/dify-agent/src/dify_agent/agent_stub/cli/main.py b/dify-agent/src/dify_agent/agent_stub/cli/main.py index 1c1e328e436..6ccc9865989 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/main.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/main.py @@ -22,7 +22,6 @@ from dify_agent.agent_stub.cli._config import ( delete_config_files_from_environment, delete_config_skills_from_environment, manifest_from_environment, - pull_config_env_from_environment, pull_config_files_from_environment, pull_config_note_from_environment, pull_config_skills_from_environment, @@ -68,9 +67,7 @@ config_skills_app = typer.Typer(help="Pull or update config skills through the A config_files_app = typer.Typer(help="Pull or update config files through the Agent Stub.", rich_markup_mode=None) config_skill_pull_alias_app = typer.Typer(help="Pull config skills through the Agent Stub.", rich_markup_mode=None) config_file_pull_alias_app = typer.Typer(help="Pull config files through the Agent Stub.", rich_markup_mode=None) -config_env_app = typer.Typer( - help="Pull or update config env variables visible to the current run.", rich_markup_mode=None -) +config_env_app = typer.Typer(help="Update config env variables visible to the current run.", rich_markup_mode=None) config_note_app = typer.Typer(help="Pull or update the current config note.", rich_markup_mode=None) drive_app = typer.Typer(help="List, pull, or push agent drive files through the Agent Stub.", rich_markup_mode=None) app.add_typer(file_app, name="file") @@ -147,9 +144,13 @@ def config_skill_pull_alias( @config_skills_app.command("push") def config_skills_push( - paths: list[str] = typer.Argument(..., metavar="PATH"), + paths: list[str] = typer.Argument(..., metavar="PATH..."), ) -> None: - """Upload one or more local skill directories into the current config manifest.""" + """Upload one or more local skill directories into the current config manifest. + + Pass a directory such as ./skills/researcher that contains SKILL.md. Other files in that directory are + archived with the skill. Pushing a skill with an existing name replaces that config skill. + """ _run_config_skills_push(paths=paths) @@ -183,15 +184,15 @@ def config_file_pull_alias( @config_files_app.command("push") def config_files_push( - paths: list[str] = typer.Argument(..., metavar="PATH"), - name: str | None = typer.Option( - None, - "--name", - help="Override the target config file name when uploading exactly one local file.", - ), + paths: list[str] = typer.Argument(..., metavar="PATH..."), ) -> None: - """Upload one or more local files into the current config manifest.""" - _run_config_files_push(paths=paths, name=name) + """Upload one or more local files into the current config manifest. + + Pass one or more regular local files such as ./guide.md and ./policy.txt. To upload a folder, compress it + first and pass the archive file. The config file name is the local basename. Pushing a file with an existing config + name replaces that config file. + """ + _run_config_files_push(paths=paths) @config_files_app.command("delete") @@ -202,19 +203,15 @@ def config_files_delete( _run_config_files_delete(names=names) -@config_env_app.command("pull") -def config_env_pull( - local_path: str | None = typer.Option(None, "--to", help="Local dotenv file path."), -) -> None: - """Export visible config env values into ./.dify_conf/.env by default.""" - _run_config_env_pull(local_path=local_path) - - @config_env_app.command("push") def config_env_push( - local_path: str | None = typer.Argument(None, metavar="PATH|-"), + local_path: str = typer.Argument(..., metavar="PATH|-"), ) -> None: - """Replace visible config env values from one local dotenv file or stdin.""" + """Set or delete config env entries from one local dotenv file or stdin. + + Pass dotenv text with lines like API_KEY=value and OLD_KEY=. KEY=value sets or updates an entry, KEY= deletes + it, and omitted keys are unchanged. + """ _run_config_env_push(local_path=local_path) @@ -228,9 +225,13 @@ def config_note_pull( @config_note_app.command("push") def config_note_push( - local_path: str | None = typer.Argument(None, metavar="PATH|-"), + local_path: str | None = typer.Argument(None, metavar="[PATH|-]"), ) -> None: - """Replace the current config note from one local text file or stdin.""" + """Replace the current config note from one local text file or stdin. + + Pass a plain text or Markdown note file, or use - to read the note from stdin. When PATH is omitted, the + command reads ./.dify_conf/note.md. + """ _run_config_note_push(local_path=local_path) @@ -448,18 +449,6 @@ def _run_config_file_pull(*, names: list[str] | None, local_dir: str | None, jso typer.echo(item.path) -def _run_config_env_pull(*, local_path: str | None) -> None: - try: - path = pull_config_env_from_environment(local_path=local_path) - except MissingAgentStubEnvironmentError as exc: - typer.echo(str(exc), err=True) - raise SystemExit(2) from exc - except AgentStubClientError as exc: - typer.echo(str(exc), err=True) - raise SystemExit(1) from exc - typer.echo(str(path)) - - def _run_config_note_pull(*, local_path: str | None) -> None: try: path = pull_config_note_from_environment(local_path=local_path) @@ -484,7 +473,7 @@ def _run_config_note_push(*, local_path: str | None) -> None: typer.echo(response.model_dump_json()) -def _run_config_env_push(*, local_path: str | None) -> None: +def _run_config_env_push(*, local_path: str) -> None: try: response = push_config_env_from_environment(local_path=local_path) except MissingAgentStubEnvironmentError as exc: @@ -496,9 +485,9 @@ def _run_config_env_push(*, local_path: str | None) -> None: typer.echo(response.model_dump_json()) -def _run_config_files_push(*, paths: list[str], name: str | None) -> None: +def _run_config_files_push(*, paths: list[str]) -> None: try: - response = push_config_files_from_environment(paths=paths, name=name) + response = push_config_files_from_environment(paths=paths) except MissingAgentStubEnvironmentError as exc: typer.echo(str(exc), err=True) raise SystemExit(2) from exc diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py index b76e74cd354..704b099a4de 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub.py @@ -289,7 +289,7 @@ def request_agent_stub_config_env_update_sync( timeout: float | httpx.Timeout = 30.0, sync_http_client: httpx.Client | None = None, ): - """Replace or delete config env entries through the HTTP transport.""" + """Set or delete config env entries through the HTTP transport.""" endpoint = _parse_endpoint(url) if endpoint.is_grpc: raise AgentStubValidationError("Agent Stub config operations require an HTTP Agent Stub URL") diff --git a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py index 49c7c83ae94..beb777c58bd 100644 --- a/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py +++ b/dify-agent/src/dify_agent/agent_stub/client/_agent_stub_http.py @@ -338,7 +338,7 @@ def request_agent_stub_config_env_update_http_sync( timeout: float | httpx.Timeout = 30.0, sync_http_client: httpx.Client | None = None, ) -> dict[str, object]: - """Replace or delete config env entries through the HTTP Agent Stub endpoint.""" + """Set or delete config env entries through the HTTP Agent Stub endpoint.""" request_model = AgentStubConfigEnvUpdateRequest(env_text=env_text) response = _send_agent_stub_json( method="PATCH", diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py index cedbb6e862d..06cb78d32fa 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py +++ b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_files.py @@ -84,8 +84,8 @@ class DifyApiAgentStubFileRequestHandler: """Call Dify API inner file request endpoints on behalf of the sandbox. The upload path calls ``/inner/api/upload/file/request`` and injects the - authenticated execution context's ``tenant_id`` and ``user_id`` along with - the requested filename and mimetype. The download path calls + authenticated execution context's ``tenant_id``, ``user_id``, and optional + ``conversation_id`` along with the requested filename and mimetype. The download path calls ``/inner/api/download/file/request`` and injects ``tenant_id``, ``user_id``, ``user_from``, and ``invoke_from`` plus the validated public file mapping. @@ -126,6 +126,7 @@ class DifyApiAgentStubFileRequestHandler: "user_id": execution_context.user_id, "filename": request.filename, "mimetype": request.mimetype, + "conversation_id": execution_context.conversation_id, } data = await self._post_inner_api("/inner/api/upload/file/request", payload) upload_url = data.get("url") diff --git a/dify-agent/src/dify_agent/layers/config/layer.py b/dify-agent/src/dify_agent/layers/config/layer.py index 5bec83d6173..d914f599efb 100644 --- a/dify-agent/src/dify_agent/layers/config/layer.py +++ b/dify-agent/src/dify_agent/layers/config/layer.py @@ -19,6 +19,7 @@ from dify_agent.layers.config.configs import ( from dify_agent.layers.shell.layer import DifyShellLayer _CONFIG_CONTEXT_HEADING = "Agent config context from the current Agent Soul:" +_CONFIG_CONTEXT_COMMAND = "$ dify-agent config manifest" _CONFIG_CLI_USAGE_PROMPT = """Agent config CLI usage is available inside shell jobs. The command help below is generated from the same `dify-agent` CLI definitions available in shell jobs. @@ -30,7 +31,6 @@ _CONFIG_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = { "dify-agent config manifest --help": ("config", "manifest"), "dify-agent config skills pull --help": ("config", "skills", "pull"), "dify-agent config files pull --help": ("config", "files", "pull"), - "dify-agent config env pull --help": ("config", "env", "pull"), "dify-agent config note pull --help": ("config", "note", "pull"), } _CONFIG_CLI_MUTATION_HELP_COMMANDS: dict[str, tuple[str, ...]] = { @@ -124,7 +124,9 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf def build_suffix_prompt(self) -> str: sections: list[str] = [] if self.runtime_state.config_context_json: - sections.append(f"{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}") + sections.append( + f"{_CONFIG_CONTEXT_COMMAND}\n{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}" + ) usage_lines = [_CONFIG_CLI_USAGE_PROMPT] if cli_help := self._format_config_cli_help(): usage_lines.append(cli_help) diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py index dd6c0c3cfb2..6e43566e589 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py @@ -7,19 +7,22 @@ only validates hidden/manual inputs, prepares invocation arguments, and maps daemon responses into agent-friendly observations. Like the LLM layer, this layer never owns live HTTP clients. The runtime passes -the FastAPI lifespan-owned shared client into ``get_tools`` so the layer can -build Pydantic AI tool adapters on demand. +the FastAPI lifespan-owned shared plugin-daemon and Dify API clients into +``get_tools`` so the layer can build Pydantic AI tool adapters on demand. """ from __future__ import annotations from copy import deepcopy import json +import mimetypes from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import ClassVar +from urllib.parse import urlparse import httpx +from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError from pydantic_ai import RunContext, Tool from pydantic_ai.tools import ToolDefinition from typing_extensions import Self, override @@ -38,7 +41,9 @@ from dify_agent.layers.dify_plugin.tool_client import ( DifyPluginToolClientError, DifyPluginToolInvokeMessage, ) +from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +from dify_agent.layers.shell.layer import DifyShellLayer # Plugin tools intentionally do not expose a per-tool strictness override in the @@ -46,12 +51,107 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer # registers those tools in loose mode so daemon tool invocation stays tolerant of # plugin schema differences and older API-prepared payloads. PLUGIN_TOOL_STRICT = False +PLUGIN_FILE_DEFAULT_TYPE = "custom" +_FILE_UPLOAD_BEGIN = "<<>>" +_FILE_UPLOAD_END = "<<>>" +_FILE_UPLOAD_TIMEOUT_SECONDS = 60.0 +_SUPPORTED_REMOTE_URL_PREFIXES = ("http://", "https://") class DifyPluginToolsDeps(LayerDeps): """Dependencies required by ``DifyPluginToolsLayer``.""" execution_context: DifyExecutionContextLayer # pyright: ignore[reportUninitializedInstanceVariable] + shell: DifyShellLayer | None # pyright: ignore[reportUninitializedInstanceVariable] + + +class DifyPluginToolsClientConfigurationError(ValueError): + """Raised when local plugin-tool file conversion preconditions are missing.""" + + +class _BackwardsInvocationEnvelope(BaseModel): + data: JsonValue = None + error: str | None = None + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") + + +class _DownloadFileResponse(BaseModel): + filename: str + mime_type: str | None = None + size: int + download_url: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +@dataclass(slots=True) +class _DifyPluginToolFileClient: + """Resolve Dify file mappings into signed URLs for plugin file parameters.""" + + base_url: str + api_key: str = field(repr=False) + http_client: httpx.AsyncClient = field(repr=False) + + def __post_init__(self) -> None: + self.base_url = self.base_url.rstrip("/") + + async def request_download( + self, + *, + execution_context: DifyExecutionContextLayerConfig, + file_mapping: Mapping[str, object], + ) -> _DownloadFileResponse: + missing_fields = [] + if execution_context.user_id is None: + missing_fields.append("user_id") + if execution_context.user_from is None: + missing_fields.append("user_from") + if missing_fields: + missing = ", ".join(missing_fields) + raise DifyPluginToolsClientConfigurationError( + f"Missing required execution context fields for file parameters: {missing}." + ) + + payload = { + "tenant_id": execution_context.tenant_id, + "user_id": execution_context.user_id, + "user_from": execution_context.user_from, + "invoke_from": execution_context.invoke_from, + "file": dict(file_mapping), + } + try: + response = await self.http_client.post( + f"{self.base_url}/inner/api/download/file/request", + headers={ + "X-Inner-Api-Key": self.api_key, + "Content-Type": "application/json", + }, + json=payload, + ) + except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc: + raise DifyPluginToolClientError(f"Dify API file download is misconfigured: {exc}") from exc + except httpx.TimeoutException as exc: + raise DifyPluginToolClientError("Dify API file download request timed out.") from exc + except httpx.RequestError as exc: + raise DifyPluginToolClientError(f"Dify API file download request failed: {exc}") from exc + + if response.status_code >= 400: + raise DifyPluginToolClientError( + response.text or f"HTTP {response.status_code}", status_code=response.status_code + ) + try: + envelope = _BackwardsInvocationEnvelope.model_validate_json(response.text) + except ValidationError as exc: + raise DifyPluginToolClientError("Invalid Dify API file download response.") from exc + if envelope.error: + raise DifyPluginToolClientError(envelope.error, status_code=response.status_code) + if not isinstance(envelope.data, dict): + raise DifyPluginToolClientError("Dify API file download response is missing data.") + try: + return _DownloadFileResponse.model_validate(envelope.data) + except ValidationError as exc: + raise DifyPluginToolClientError("Invalid Dify API file download data.") from exc @dataclass(slots=True) @@ -61,17 +161,52 @@ class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerC type_id: ClassVar[str | None] = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID config: DifyPluginToolsLayerConfig + inner_api_url: str + inner_api_key: str @classmethod @override def from_config(cls, config: DifyPluginToolsLayerConfig) -> Self: - """Create the tools layer from validated public config.""" - return cls(config=DifyPluginToolsLayerConfig.model_validate(config)) + """Reject construction without server-injected Dify API settings.""" + del config + raise TypeError("DifyPluginToolsLayer requires server-side Dify API settings and must use a provider factory.") - async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + @classmethod + def from_config_with_settings( + cls, + config: DifyPluginToolsLayerConfig, + *, + inner_api_url: str, + inner_api_key: str, + ) -> Self: + return cls( + config=DifyPluginToolsLayerConfig.model_validate(config), + inner_api_url=inner_api_url, + inner_api_key=inner_api_key, + ) + + async def get_tools( + self, + *, + http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, + ) -> list[Tool[object]]: """Build Pydantic AI tool adapters from prepared plugin tool config.""" + if dify_api_http_client.is_closed: + raise RuntimeError("DifyPluginToolsLayer.get_tools() requires an open shared Dify API HTTP client.") + tool_clients: dict[str, DifyPluginDaemonToolClient] = {} tools: list[Tool[object]] = [] + file_client = _DifyPluginToolFileClient( + base_url=self.inner_api_url, + api_key=self.inner_api_key, + http_client=dify_api_http_client, + ) + file_context = _PluginToolFileContext( + file_client=file_client, + execution_context=self.deps.execution_context.config, + shell=self.deps.shell, + ) for tool_config in self.config.tools: client = tool_clients.get(tool_config.plugin_id) @@ -89,6 +224,7 @@ class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerC client=client, tool_config=tool_config, effective_parameters=effective_parameters, + file_context=file_context, ) ) @@ -117,6 +253,7 @@ def _build_pydantic_ai_tool( client: DifyPluginDaemonToolClient, tool_config: DifyPluginToolConfig, effective_parameters: Sequence[DifyPluginToolParameter], + file_context: "_PluginToolFileContext", ) -> Tool[object]: tool_name = tool_config.name or tool_config.tool_name tool_description = tool_config.description or tool_name @@ -124,7 +261,12 @@ def _build_pydantic_ai_tool( async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str: try: - merged_arguments = _prepare_tool_arguments(effective_parameters, tool_config, tool_arguments) + merged_arguments = await _prepare_tool_arguments( + effective_parameters, + tool_config, + tool_arguments, + file_context=file_context, + ) messages = await client.invoke( provider=tool_config.provider, tool_name=tool_config.tool_name, @@ -162,10 +304,12 @@ def _build_pydantic_ai_tool( ) -def _prepare_tool_arguments( +async def _prepare_tool_arguments( effective_parameters: Sequence[DifyPluginToolParameter], tool_config: DifyPluginToolConfig, tool_arguments: Mapping[str, object], + *, + file_context: "_PluginToolFileContext", ) -> dict[str, object]: """Build the daemon invocation payload from prepared config + model args. @@ -193,14 +337,103 @@ def _prepare_tool_arguments( raise ValueError(f"tool parameter {parameter.name} not found in tool config") else: continue - prepared_arguments[parameter.name] = _cast_tool_parameter_value(parameter.type, value) + prepared_arguments[parameter.name] = await _cast_tool_parameter_value( + parameter.type, + value, + file_context=file_context, + ) for key, value in merged_arguments.items(): prepared_arguments.setdefault(key, value) return prepared_arguments -def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, value: object) -> object: +@dataclass(slots=True) +class _PluginToolFileContext: + file_client: _DifyPluginToolFileClient + execution_context: DifyExecutionContextLayerConfig + shell: DifyShellLayer | None + + async def to_plugin_file_parameter(self, value: object) -> dict[str, object]: + if isinstance(value, str): + if _is_remote_url(value): + return _plugin_file_parameter_from_url(value) + mapping = await self._upload_sandbox_path(value) + return await self._plugin_file_parameter_from_mapping(mapping) + + if isinstance(value, Mapping): + if _is_plugin_file_parameter(value): + return _normalize_plugin_file_parameter(value) + return await self._plugin_file_parameter_from_mapping(value) + + raise ValueError("file parameter must be an HTTP(S) URL, sandbox path, or file mapping.") + + async def _upload_sandbox_path(self, path: str) -> dict[str, object]: + if self.shell is None: + raise ValueError("sandbox file path parameters require an active shell layer.") + script = ( + "python - <<'PY'\n" + "import json\n" + "import subprocess\n" + "import sys\n\n" + f"command = ['dify-agent', 'file', 'upload', {json.dumps(path)}]\n" + "completed = subprocess.run(command, capture_output=True, text=True, check=False)\n" + "if completed.returncode != 0:\n" + " print(completed.stderr or completed.stdout or f'upload exited with code {completed.returncode}', file=sys.stderr)\n" + " sys.exit(completed.returncode or 1)\n" + "try:\n" + " payload = json.loads(completed.stdout)\n" + "except ValueError as exc:\n" + " print(f'upload returned invalid JSON: {exc}', file=sys.stderr)\n" + " sys.exit(1)\n" + f"print('{_FILE_UPLOAD_BEGIN}' + json.dumps(payload, separators=(',', ':')) + '{_FILE_UPLOAD_END}')\n" + "PY" + ) + result = await self.shell.run_remote_script_complete( + script, + timeout=_FILE_UPLOAD_TIMEOUT_SECONDS, + inject_agent_stub_env=True, + ) + if result.exit_code != 0: + raise ValueError( + "sandbox file upload failed: " + + f"{result.status} exit_code={result.exit_code} output_complete={result.output_complete} " + + result.output.strip() + ) + return _parse_uploaded_file_mapping(result.output) + + async def _plugin_file_parameter_from_mapping(self, mapping: Mapping[str, object]) -> dict[str, object]: + transfer_method = mapping.get("transfer_method") + if transfer_method == "remote_url": + url = mapping.get("url") + if not isinstance(url, str) or not url: + raise ValueError("remote_url file mapping requires url.") + return _plugin_file_parameter_from_url(url) + + if transfer_method not in {"local_file", "tool_file", "datasource_file"}: + raise ValueError( + "file mapping transfer_method must be local_file, tool_file, datasource_file, or remote_url." + ) + reference = mapping.get("reference") + if not isinstance(reference, str) or not reference: + raise ValueError(f"{transfer_method} file mapping requires reference.") + + download = await self.file_client.request_download( + execution_context=self.execution_context, + file_mapping={ + "transfer_method": transfer_method, + "reference": reference, + }, + ) + return _plugin_file_parameter_from_download(download) + + +async def _cast_tool_parameter_value( + parameter_type: DifyPluginToolParameterType, + value: object, + *, + file_context: _PluginToolFileContext, +) -> object: """Cast prepared tool argument values into daemon-facing wire shapes. The API side prepares declaration metadata, but the actual invocation payload @@ -235,13 +468,14 @@ def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, valu return float(value) if "." in value else int(value) return value case DifyPluginToolParameterType.SYSTEM_FILES | DifyPluginToolParameterType.FILES: - return value if isinstance(value, list) else [value] + values = value if isinstance(value, list) else [value] + return [await file_context.to_plugin_file_parameter(item) for item in values] case DifyPluginToolParameterType.FILE: if isinstance(value, list): if len(value) != 1: raise ValueError("This parameter only accepts one file but got multiple files while invoking.") - return value[0] - return value + return await file_context.to_plugin_file_parameter(value[0]) + return await file_context.to_plugin_file_parameter(value) case DifyPluginToolParameterType.MODEL_SELECTOR | DifyPluginToolParameterType.APP_SELECTOR: if not isinstance(value, dict): raise ValueError("The selector must be a dictionary.") @@ -276,6 +510,112 @@ def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, valu raise AssertionError(f"Unsupported tool parameter type: {parameter_type}") +def _is_remote_url(value: str) -> bool: + return value.lower().startswith(_SUPPORTED_REMOTE_URL_PREFIXES) + + +def _is_plugin_file_parameter(value: Mapping[str, object]) -> bool: + url = value.get("url") + return isinstance(url, str) and bool(url) and "transfer_method" not in value + + +def _normalize_plugin_file_parameter(value: Mapping[str, object]) -> dict[str, object]: + url = value.get("url") + if not isinstance(url, str) or not url: + raise ValueError("plugin file parameter requires url.") + return _plugin_file_parameter( + url=url, + filename=value.get("filename"), + mime_type=value.get("mime_type"), + extension=value.get("extension"), + size=value.get("size"), + file_type=value.get("type"), + ) + + +def _plugin_file_parameter_from_url(url: str) -> dict[str, object]: + if not _is_remote_url(url): + raise ValueError("remote file URL must start with http:// or https://.") + filename = _filename_from_url(url) + mime_type = mimetypes.guess_type(filename or url)[0] if filename else None + return _plugin_file_parameter( + url=url, + filename=filename, + mime_type=mime_type, + extension=_extension_from_filename(filename), + size=-1, + file_type=PLUGIN_FILE_DEFAULT_TYPE, + ) + + +def _plugin_file_parameter_from_download(download: _DownloadFileResponse) -> dict[str, object]: + return _plugin_file_parameter( + url=download.download_url, + filename=download.filename, + mime_type=download.mime_type, + extension=_extension_from_filename(download.filename), + size=download.size, + file_type=PLUGIN_FILE_DEFAULT_TYPE, + ) + + +def _plugin_file_parameter( + *, + url: str, + filename: object, + mime_type: object, + extension: object, + size: object, + file_type: object, +) -> dict[str, object]: + normalized: dict[str, object] = { + "dify_model_identity": "__dify__file__", + "type": file_type if isinstance(file_type, str) and file_type else PLUGIN_FILE_DEFAULT_TYPE, + "url": url, + } + if isinstance(filename, str) and filename: + normalized["filename"] = filename + if isinstance(mime_type, str) and mime_type: + normalized["mime_type"] = mime_type + if isinstance(extension, str) and extension: + normalized["extension"] = extension + if isinstance(size, int): + normalized["size"] = size + return normalized + + +def _filename_from_url(url: str) -> str | None: + parsed = urlparse(url) + filename = parsed.path.rsplit("/", 1)[-1] + return filename or None + + +def _extension_from_filename(filename: str | None) -> str | None: + if not filename or "." not in filename: + return None + suffix = "." + filename.rsplit(".", 1)[-1] + return suffix if len(suffix) > 1 else None + + +def _parse_uploaded_file_mapping(output: str) -> dict[str, object]: + begin_index = output.find(_FILE_UPLOAD_BEGIN) + end_index = output.find(_FILE_UPLOAD_END, begin_index + len(_FILE_UPLOAD_BEGIN)) + if begin_index < 0 or end_index < 0: + raise ValueError("sandbox file upload did not return a framed file mapping.") + raw_payload = output[begin_index + len(_FILE_UPLOAD_BEGIN) : end_index] + try: + payload = json.loads(raw_payload) + except json.JSONDecodeError as exc: + raise ValueError("sandbox file upload returned invalid file mapping JSON.") from exc + if not isinstance(payload, dict): + raise ValueError("sandbox file upload returned a non-object file mapping.") + transfer_method = payload.get("transfer_method") + reference = payload.get("reference") + if transfer_method != "tool_file" or not isinstance(reference, str) or not reference: + raise ValueError("sandbox file upload returned an invalid tool_file mapping.") + return {"transfer_method": "tool_file", "reference": reference} + + def _tool_error_text(*, tool_name: str, error: DifyPluginToolClientError) -> str: """Map expected daemon/tool failures into agent-visible observation text. diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index d514025aa45..7346acc7b33 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -1,4 +1,15 @@ -"""Shell runtime layer backed by a live shell provider resource.""" +"""Shell runtime layer backed by a live shell provider resource. + +Shell command execution requires a bound execution-context layer with a safe +``agent_id``. The layer uses the current bound execution context to run +commands with ``HOME=/home/`` and a home-rooted workspace path. The +persisted runtime state intentionally keeps the historical +``~/workspace/`` identity so existing session snapshots stay +compatible while live command execution no longer depends on the sandbox user's +ambient home directory. Entering or re-entering the layer re-ensures the live +home/workspace directories for the currently bound ``agent_id`` before user +commands are sent. +""" from __future__ import annotations @@ -10,13 +21,22 @@ import re import secrets import time from dataclasses import dataclass -from typing import ClassVar, Literal, NotRequired, TypedDict +from typing import ClassVar, Literal, NotRequired, Protocol, TypedDict, runtime_checkable from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator from pydantic_ai import Tool from typing_extensions import Self, override -from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool +from agenton.layers import ( + EmptyLayerConfig, + EmptyRuntimeState, + LayerDeps, + NoLayerDeps, + PlainLayer, + PydanticAILayer, + PydanticAIPrompt, + PydanticAITool, +) from dify_agent.adapters.shell.protocols import ( CompleteShellCommandResult, ShellCommandProtocol, @@ -26,20 +46,35 @@ from dify_agent.adapters.shell.protocols import ( ShellResourceProtocol, ) from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env -from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix +try: + from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer +except ModuleNotFoundError: + + class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]): + """Minimal fallback for shell-only imports without server extras installed.""" + + logger = logging.getLogger(__name__) + +@runtime_checkable +class _HasErrorCode(Protocol): + code: object + + DEFAULT_TIMEOUT_SECONDS = 30.0 DEFAULT_TERMINATE_GRACE_SECONDS = 10.0 _WORKSPACE_ROOT = "~/workspace" +_WORKSPACE_DIR_NAME = "workspace" _WORKSPACE_COLLISION_EXIT_CODE = 17 _SESSION_TIME_HEX_MASK = 0xFFFFF _SESSION_RANDOM_HEX_LENGTH = 2 _SESSION_ID_ATTEMPT_LIMIT = 256 _SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$") +_AGENT_HOME_SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$") _SHELL_OUTPUT_PROMPT_EDGE_BYTES = 8 * 1024 _SHELLCTL_OUTPUT_LIMIT_BYTES = 2 * _SHELL_OUTPUT_PROMPT_EDGE_BYTES _REMOTE_COMPLETE_OUTPUT_MAX_BYTES = 1024 * 1024 @@ -123,6 +158,8 @@ from rich import print response = httpx.get("https://example.com", timeout=10) print(f"[green]status:[/green] {response.status_code}")""" +_SHELL_LAYER_SUFFIX_PROMPT = """Environment variables may contain API keys, tokens, or credentials. +You may refer to environment variable names when needed.""" class ShellToolErrorObservation(TypedDict): @@ -219,6 +256,11 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC def prefix_prompts(self) -> Sequence[PydanticAIPrompt[object]]: return [_shell_layer_prefix_prompt] + @property + @override + def suffix_prompts(self) -> Sequence[PydanticAIPrompt[object]]: + return [_shell_layer_suffix_prompt] + @property @override def tools(self) -> Sequence[PydanticAITool[object]]: @@ -248,7 +290,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC session_id: str | None = None try: session_id, workspace_cwd = await self._allocate_workspace() - await self._bootstrap_workspace(workspace_cwd) + await self._bootstrap_workspace(session_id) except BaseException: if session_id is not None: await self._cleanup_workspace_best_effort(session_id) @@ -264,7 +306,8 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC @override async def on_context_resume(self) -> None: _ = self._require_resource() - _ = self._require_session_identity() + session_id, _workspace_cwd = self._require_session_identity() + await self._ensure_live_workspace_exists(session_id) @override async def on_context_suspend(self) -> None: @@ -295,7 +338,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC result = await self._require_resource().commands.run( _wrap_user_script(script, self.config), cwd=self._require_workspace_cwd(), - env=self._build_user_shell_run_env(), + env=self._build_shell_command_env(include_agent_stub_env=True), timeout=timeout, ) observation = await render_prompt_observation_from_result( @@ -316,7 +359,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC observation.text, ) except (RuntimeError, ValueError) as exc: - return _tool_error(str(exc)) + return _tool_error_from_exception(exc) + except Exception as exc: + return _tool_unexpected_error("shell_run", exc, session_id=self.runtime_state.session_id) async def _tool_wait(self, job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: try: @@ -340,7 +385,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC observation.text, ) except (RuntimeError, ValueError) as exc: - return _tool_error(str(exc), job_id=job_id) + return _tool_error_from_exception(exc, job_id=job_id) + except Exception as exc: + return _tool_unexpected_error("shell_wait", exc, session_id=self.runtime_state.session_id, job_id=job_id) async def _tool_input(self, job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult: try: @@ -364,7 +411,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC observation.text, ) except (RuntimeError, ValueError) as exc: - return _tool_error(str(exc), job_id=job_id) + return _tool_error_from_exception(exc, job_id=job_id) + except Exception as exc: + return _tool_unexpected_error("shell_input", exc, session_id=self.runtime_state.session_id, job_id=job_id) async def _tool_interrupt( self, @@ -378,6 +427,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self._remember_job_offset(result.job_id, result.offset) output_path: str | None = None try: + # Once the interrupt itself succeeds, resolving the output path is + # best-effort metadata enrichment and must not turn the interrupt + # into a failed tool result. output_path = (await self._require_resource().commands.tail(job_id)).output_path except (RuntimeError, ValueError) as exc: logger.warning( @@ -386,6 +438,12 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self.runtime_state.session_id, exc, ) + except Exception: + logger.exception( + "Failed to fetch output path for interrupted shell job %s in session %s", + job_id, + self.runtime_state.session_id, + ) return _tagged_shell_observation( _metadata_dict( job_id=result.job_id, @@ -397,7 +455,11 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC "Job was interrupted.", ) except (RuntimeError, ValueError) as exc: - return _tool_error(str(exc), job_id=job_id) + return _tool_error_from_exception(exc, job_id=job_id) + except Exception as exc: + return _tool_unexpected_error( + "shell_interrupt", exc, session_id=self.runtime_state.session_id, job_id=job_id + ) async def run_remote_script_complete( self, @@ -407,16 +469,14 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC max_output_bytes: int = _REMOTE_COMPLETE_OUTPUT_MAX_BYTES, inject_agent_stub_env: bool = False, ) -> CompleteRemoteCommandResult: - env = None - if inject_agent_stub_env: - env = self._build_user_shell_run_env() - if env is None: - raise RuntimeError("Agent Stub environment injection is not available for this shell session.") return await execute_complete_with_commands( self._require_resource().commands, script, cwd=self._require_workspace_cwd(), - env=env, + env=self._build_shell_command_env( + include_agent_stub_env=inject_agent_stub_env, + require_agent_stub_env=inject_agent_stub_env, + ), timeout=timeout, max_output_bytes=max_output_bytes, ) @@ -448,10 +508,11 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC return session_id, _workspace_cwd(session_id) raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.") - async def _bootstrap_workspace(self, workspace_cwd: str) -> None: + async def _bootstrap_workspace(self, session_id: str) -> None: bootstrap_script = _workspace_bootstrap_script(self.config) if not bootstrap_script: return + workspace_cwd = _workspace_cwd_for_home(self._shell_home_dir(), session_id) result = await self._run_internal_script_complete(bootstrap_script, cwd=workspace_cwd) if result.exit_code != 0 or not result.output_complete: raise RuntimeError( @@ -464,6 +525,14 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC except (RuntimeError, ValueError) as exc: logger.warning("Failed to remove shell workspace for session %s after create failure: %s", session_id, exc) + async def _ensure_live_workspace_exists(self, session_id: str) -> None: + result = await self._run_internal_script_complete(_workspace_ensure_script(session_id=session_id), cwd=None) + if result.exit_code != 0 or not result.output_complete: + raise RuntimeError( + f"Failed to ensure shell workspace {_workspace_cwd_for_home(self._shell_home_dir(), session_id)} " + + f"exists: {result.status} exit_code={result.exit_code}" + ) + async def _run_internal_script_complete( self, script: str, @@ -474,7 +543,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self._require_resource().commands, script, cwd=cwd, - env=None, + env=self._build_shell_command_env(include_agent_stub_env=False), timeout=DEFAULT_TIMEOUT_SECONDS, max_output_bytes=_REMOTE_COMPLETE_OUTPUT_MAX_BYTES, ) @@ -488,8 +557,8 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC return self._shell_resource def _require_workspace_cwd(self) -> str: - _session_id, workspace_cwd = self._require_session_identity() - return workspace_cwd + session_id, _workspace_cwd = self._require_session_identity() + return _workspace_cwd_for_home(self._shell_home_dir(), session_id) def _require_session_identity(self) -> tuple[str, str]: identity = self._try_session_identity() @@ -545,16 +614,45 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC self.runtime_state.job_offsets = {} self.runtime_state.job_ids = [] - def _build_user_shell_run_env(self) -> dict[str, str] | None: + def _shell_home_dir(self) -> str: + return _shell_home_dir_for_agent_id(self._require_current_execution_agent_id()) + + def _current_execution_agent_id(self) -> str | None: execution_context_layer = self.deps.execution_context execution_context = execution_context_layer.config if execution_context_layer is not None else None - return build_shell_agent_stub_env( + return execution_context.agent_id if execution_context is not None else None + + def _require_current_execution_agent_id(self) -> str: + agent_id = self._current_execution_agent_id() + if agent_id is None: + raise ValueError("ShellLayer command execution requires execution_context.agent_id.") + return _validated_agent_home_segment(agent_id) + + def _build_shell_command_env( + self, + *, + include_agent_stub_env: bool, + require_agent_stub_env: bool = False, + ) -> dict[str, str]: + env = _shell_config_env(self.config) + env["HOME"] = self._shell_home_dir() + if not include_agent_stub_env: + return env + execution_context_layer = self.deps.execution_context + execution_context = execution_context_layer.config if execution_context_layer is not None else None + agent_stub_env = build_shell_agent_stub_env( agent_stub_api_base_url=self.agent_stub_api_base_url, agent_stub_drive_ref=self.config.agent_stub_drive_ref, execution_context=execution_context, token_factory=self.agent_stub_token_factory, session_id=self.runtime_state.session_id, ) + if agent_stub_env is None: + if not require_agent_stub_env: + return env + raise RuntimeError("Agent Stub environment injection is not available for this shell session.") + env.update(agent_stub_env) + return env async def execute_complete_with_commands( @@ -663,6 +761,10 @@ def _shell_layer_prefix_prompt() -> str: return _SHELL_LAYER_PREFIX_PROMPT +def _shell_layer_suffix_prompt() -> str: + return _SHELL_LAYER_SUFFIX_PROMPT + + def _metadata_dict( *, job_id: str, @@ -689,6 +791,35 @@ def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObs return result +def _tool_error_from_exception(exc: Exception, *, job_id: str | None = None) -> ShellToolErrorObservation: + # Expected provider/runtime failures stay inside the tool contract and are + # returned to the model as observations. The broader Exception fallback + # below handles unexpected failures; BaseException, including cancellation, + # is intentionally left uncaught at the tool boundary. + if isinstance(exc, _HasErrorCode) and isinstance(exc.code, str) and exc.code: + return _tool_error(f"{exc.code}: {exc}", job_id=job_id) + return _tool_error(str(exc), job_id=job_id) + + +def _tool_unexpected_error( + tool_name: str, + exc: Exception, + *, + session_id: str | None, + job_id: str | None = None, +) -> ShellToolErrorObservation: + # Unexpected Exception still becomes a tool observation so one shell tool + # failure does not abort the agent loop, but it is logged with traceback for + # debugging. BaseException is intentionally not caught by callers. + logger.exception( + "Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", + tool_name, + session_id, + job_id, + ) + return _tool_error_from_exception(exc, job_id=job_id) + + def _generate_session_id() -> str: time_component = int(time.time()) & _SESSION_TIME_HEX_MASK random_component = secrets.token_hex(1) @@ -701,6 +832,22 @@ def _workspace_cwd(session_id: str) -> str: return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}" +def _shell_home_dir_for_agent_id(agent_id: str | None) -> str: + if agent_id is None: + raise ValueError("ShellLayer command execution requires execution_context.agent_id.") + return f"/home/{_validated_agent_home_segment(agent_id)}" + + +def _validated_agent_home_segment(agent_id: str) -> str: + if agent_id in {".", ".."} or not _AGENT_HOME_SEGMENT_PATTERN.fullmatch(agent_id): + raise ValueError("execution_context.agent_id must be a safe single path segment for shell HOME.") + return agent_id + + +def _workspace_cwd_for_home(home_dir: str, session_id: str) -> str: + return f"{home_dir}/{_WORKSPACE_DIR_NAME}/{_validated_session_id(session_id)}" + + def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: install_commands = [command for tool in config.cli_tools for command in tool.install_commands] if not install_commands: @@ -711,13 +858,11 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]: lines: list[str] = [] - for env_var in config.env: - lines.append(f"export {env_var.name}={_shquote(env_var.value)}") + # Plain env values travel through ShellCommandProtocol.run(env=...) so inline + # secrets are not rendered into generated shell scripts. for secret_ref in config.secret_refs: lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') for tool in config.cli_tools: - for env_var in tool.env: - lines.append(f"export {env_var.name}={_shquote(env_var.value)}") for secret_ref in tool.secret_refs: lines.append(f'export {secret_ref.name}="${{{secret_ref.name}:-}}"') if config.sandbox is not None: @@ -729,6 +874,16 @@ def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]: return lines +def _shell_config_env(config: DifyShellLayerConfig) -> dict[str, str]: + env: dict[str, str] = {} + for env_var in config.env: + env[env_var.name] = env_var.value + for tool in config.cli_tools: + for env_var in tool.env: + env[env_var.name] = env_var.value + return env + + def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str: lines = _shell_config_export_lines(config) if not lines: @@ -751,6 +906,10 @@ def _workspace_cleanup_script(*, session_id: str) -> str: return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"' +def _workspace_ensure_script(*, session_id: str) -> str: + return f'mkdir -p "$HOME/workspace/{_validated_session_id(session_id)}"' + + def _shquote(value: str) -> str: return "'" + value.replace("'", "'\\''") + "'" diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py index 46dc9c0ea10..a43b6cd4812 100644 --- a/dify-agent/src/dify_agent/protocol/__init__.py +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -9,6 +9,7 @@ from .schemas import ( DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID, RUN_EVENT_ADAPTER, + AgentRunUsage, BaseRunEvent, CancelRunRequest, CancelRunResponse, @@ -55,6 +56,7 @@ from .sandbox import ( __all__ = [ "BaseRunEvent", + "AgentRunUsage", "CancelRunRequest", "CancelRunResponse", "CreateRunRequest", diff --git a/dify-agent/src/dify_agent/protocol/schemas.py b/dify-agent/src/dify_agent/protocol/schemas.py index 77501942666..1a02c7232a4 100644 --- a/dify-agent/src/dify_agent/protocol/schemas.py +++ b/dify-agent/src/dify_agent/protocol/schemas.py @@ -252,12 +252,29 @@ class DeferredToolCallPayload(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class AgentRunUsage(BaseModel): + """Token usage reported by the model request behind one Agent run.""" + + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def _derive_total_tokens(self) -> AgentRunUsage: + if self.total_tokens == 0 and (self.prompt_tokens > 0 or self.completion_tokens > 0): + self.total_tokens = self.prompt_tokens + self.completion_tokens + return self + + class RunSucceededEventData(BaseModel): """Terminal success payload for final output or deferred tool continuation.""" output: JsonValue | None = None deferred_tool_call: DeferredToolCallPayload | None = None session_snapshot: CompositorSessionSnapshot + usage: AgentRunUsage | None = None model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -276,6 +293,8 @@ class RunSucceededEventData(BaseModel): data["output"] = self.output if "deferred_tool_call" in self.model_fields_set: data["deferred_tool_call"] = self.deferred_tool_call + if self.usage is not None: + data["usage"] = self.usage return data @@ -361,6 +380,7 @@ class RunEventsResponse(BaseModel): __all__ = [ "BaseRunEvent", + "AgentRunUsage", "CancelRunRequest", "CancelRunResponse", "CreateRunRequest", diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 9772663c020..3d1e2afc488 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -42,6 +42,7 @@ from dify_agent.layers.config.layer import DifyConfigLayer from dify_agent.layers.dify_core_tools.configs import DifyCoreToolsLayerConfig from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer +from dify_agent.layers.dify_plugin.configs import DifyPluginToolsLayerConfig from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer from dify_agent.layers.drive.layer import DifyDriveLayer from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig @@ -128,7 +129,14 @@ def create_default_layer_providers( ), ), LayerProvider.from_layer_type(DifyPluginLLMLayer), - LayerProvider.from_layer_type(DifyPluginToolsLayer), + LayerProvider.from_factory( + layer_type=DifyPluginToolsLayer, + create=lambda config: DifyPluginToolsLayer.from_config_with_settings( + DifyPluginToolsLayerConfig.model_validate(config), + inner_api_url=inner_api_url, + inner_api_key=inner_api_key, + ), + ), LayerProvider.from_factory( layer_type=DifyCoreToolsLayer, create=lambda config: DifyCoreToolsLayer.from_config_with_settings( diff --git a/dify-agent/src/dify_agent/runtime/event_sink.py b/dify-agent/src/dify_agent/runtime/event_sink.py index 80cbf76cbd9..5dbe9fce62d 100644 --- a/dify-agent/src/dify_agent/runtime/event_sink.py +++ b/dify-agent/src/dify_agent/runtime/event_sink.py @@ -17,6 +17,7 @@ from pydantic_ai.messages import AgentStreamEvent from agenton.compositor import CompositorSessionSnapshot from dify_agent.protocol.schemas import ( + AgentRunUsage, DeferredToolCallPayload, EmptyRunEventData, PydanticAIStreamRunEvent, @@ -103,6 +104,7 @@ async def emit_run_succeeded( output: JsonValue | None | object = _UNSET, deferred_tool_call: DeferredToolCallPayload | object = _UNSET, session_snapshot: CompositorSessionSnapshot, + usage: AgentRunUsage | None = None, ) -> str: """Emit the terminal success event with output or deferred continuation. @@ -112,13 +114,15 @@ async def emit_run_succeeded( Without that sentinel, ``output=None`` would be indistinguishable from “output field absent”, which would break nullable-success payloads. """ - data: dict[str, JsonValue | DeferredToolCallPayload | CompositorSessionSnapshot | None] = { + data: dict[str, JsonValue | DeferredToolCallPayload | CompositorSessionSnapshot | AgentRunUsage | None] = { "session_snapshot": session_snapshot, } if output is not _UNSET: data["output"] = cast(JsonValue | None, output) if deferred_tool_call is not _UNSET: data["deferred_tool_call"] = cast(DeferredToolCallPayload, deferred_tool_call) + if usage is not None: + data["usage"] = usage return await emit_run_event( sink, diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index 65946736f02..97fbfe4ba4c 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -24,10 +24,10 @@ both the JSON-safe final output or deferred tool call and the session snapshot; there are no separate output or snapshot events to correlate. """ -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, Callable from collections import Counter from dataclasses import dataclass -from typing import Any, Literal, cast +from typing import Any, Literal, Protocol, cast, runtime_checkable import httpx from pydantic import JsonValue, TypeAdapter @@ -43,6 +43,7 @@ from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer from dify_agent.protocol.schemas import ( + AgentRunUsage, CreateRunRequest, DIFY_AGENT_MODEL_LAYER_ID, DeferredToolCallPayload, @@ -72,6 +73,26 @@ from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, _AGENT_OUTPUT_ADAPTER = TypeAdapter(object) +@runtime_checkable +class _HasUsage(Protocol): + usage: object + + +@runtime_checkable +class _HasInputTokens(Protocol): + input_tokens: object + + +@runtime_checkable +class _HasOutputTokens(Protocol): + output_tokens: object + + +@runtime_checkable +class _HasTotalTokens(Protocol): + total_tokens: object + + class AgentRunValidationError(ValueError): """Raised when a run request is valid JSON but cannot execute.""" @@ -84,6 +105,7 @@ class RunSuccessOutcome: output: JsonValue | None deferred_tool_call: DeferredToolCallPayload | None session_snapshot: CompositorSessionSnapshot + usage: AgentRunUsage | None class AgentRunRunner: @@ -136,6 +158,7 @@ class AgentRunRunner: else {"deferred_tool_call": outcome.deferred_tool_call} ), session_snapshot=outcome.session_snapshot, + usage=outcome.usage, ) await self.sink.update_status(self.run_id, "succeeded") @@ -171,6 +194,7 @@ class AgentRunRunner: output: JsonValue | None = None deferred_tool_call: DeferredToolCallPayload | None = None result_kind: Literal["output", "deferred_tool_call"] | None = None + usage: AgentRunUsage | None = None try: async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run: entered_run = True @@ -218,6 +242,7 @@ class AgentRunRunner: deferred_tool_results=deferred_tool_results, event_stream_handler=handle_events, ) + usage = _serialize_agent_usage(_result_usage(result)) append_successful_run_history(history_layer, result.new_messages()) if isinstance(result.output, DeferredToolRequests): if ask_human_layer is None: @@ -252,6 +277,7 @@ class AgentRunRunner: output=output, deferred_tool_call=deferred_tool_call, session_snapshot=run.session_snapshot, + usage=usage, ) @@ -260,6 +286,34 @@ def _serialize_agent_output(output: object) -> JsonValue: return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json")) +def _result_usage(result: object) -> object | None: + """Return pydantic-ai result usage across method/property API variants.""" + if not isinstance(result, _HasUsage): + return None + + usage = result.usage + if isinstance(usage, _HasInputTokens) or isinstance(usage, _HasOutputTokens): + return usage + if callable(usage): + usage_getter = cast(Callable[[], object], usage) + return usage_getter() + return usage + + +def _serialize_agent_usage(usage: object | None) -> AgentRunUsage | None: + """Convert pydantic-ai request usage into the public Agent run usage shape.""" + if usage is None: + return None + input_tokens = int(usage.input_tokens or 0) if isinstance(usage, _HasInputTokens) else 0 + output_tokens = int(usage.output_tokens or 0) if isinstance(usage, _HasOutputTokens) else 0 + total_tokens = int(usage.total_tokens or 0) if isinstance(usage, _HasTotalTokens) else 0 + return AgentRunUsage( + prompt_tokens=input_tokens, + completion_tokens=output_tokens, + total_tokens=total_tokens, + ) + + def _resolve_agent_output_type(output_type: OutputSpec[object], allow_deferred_tools: bool) -> OutputSpec[object]: """Return the run output type, optionally augmented with deferred-tool support.""" if not allow_deferred_tools: @@ -285,7 +339,12 @@ async def _resolve_run_tools( for slot in run.slots.values(): layer = slot.layer if isinstance(layer, DifyPluginToolsLayer): - resolved_tools.extend(await layer.get_tools(http_client=plugin_daemon_http_client)) + resolved_tools.extend( + await layer.get_tools( + http_client=plugin_daemon_http_client, + dify_api_http_client=dify_api_http_client, + ) + ) if isinstance(layer, DifyCoreToolsLayer): resolved_tools.extend(await layer.get_tools(http_client=dify_api_http_client)) if isinstance(layer, DifyKnowledgeBaseLayer): diff --git a/dify-agent/src/dify_agent/server/sandbox_files.py b/dify-agent/src/dify_agent/server/sandbox_files.py index 78f5210b6aa..9d712f2db3e 100644 --- a/dify-agent/src/dify_agent/server/sandbox_files.py +++ b/dify-agent/src/dify_agent/server/sandbox_files.py @@ -9,7 +9,11 @@ minimal compositor from ``SandboxLocator``, enters the saved The scripts still frame their structured payloads with a PTY-safe base64-between-sentinels envelope. shellctl jobs are tmux-backed, so raw JSON can be wrapped or surrounded by prompt noise; the framing keeps list/read/upload -responses parseable without falling back to direct shellctl file access. +responses parseable without falling back to direct shellctl file access. Path +arguments resolve from the saved shell workspace cwd, and ``~`` resolves through +the shell layer's injected sandbox ``HOME``. The scripts do not re-impose a +workspace-root boundary, so callers can use ``../`` or ``~/...`` when the sandbox +filesystem layout expects it. """ from __future__ import annotations @@ -63,21 +67,9 @@ def emit(payload): print(BEGIN + blob + END) -def resolve_target(root: Path, raw_path: str) -> tuple[Path | None, str]: - target = (root / raw_path).resolve() - if target != root and root not in target.parents: - emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) - return None, raw_path - return target, raw_path - - -root = Path.cwd().resolve() raw_path = sys.argv[1] limit = int(sys.argv[2]) -target_info = resolve_target(root, raw_path) -if target_info[0] is None: - sys.exit(0) -target, normalized_path = target_info +target = Path(raw_path).expanduser().resolve() if not target.exists(): emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) @@ -109,7 +101,7 @@ for child in sorted(target.iterdir(), key=lambda item: item.name)[:limit]: emit( { - "path": normalized_path, + "path": raw_path, "entries": entries, "truncated": len(list(target.iterdir())) > limit, } @@ -132,13 +124,9 @@ def emit(payload): print(BEGIN + blob + END) -root = Path.cwd().resolve() raw_path = sys.argv[1] max_bytes = int(sys.argv[2]) -target = (root / raw_path).resolve() -if target != root and root not in target.parents: - emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) - sys.exit(0) +target = Path(raw_path).expanduser().resolve() if not target.exists(): emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) sys.exit(0) @@ -194,12 +182,8 @@ def emit(payload): print(BEGIN + blob + END) -root = Path.cwd().resolve() raw_path = sys.argv[1] -target = (root / raw_path).resolve() -if target != root and root not in target.parents: - emit({"error": "invalid_sandbox_path", "message": "path escapes the sandbox workspace"}) - sys.exit(0) +target = Path(raw_path).expanduser().resolve() if not target.exists(): emit({"error": "sandbox_path_not_found", "message": "path not found in sandbox"}) sys.exit(0) @@ -310,15 +294,25 @@ class SandboxFileService: def _normalize_sandbox_path(path: str, *, allow_current_directory: bool) -> str: + """Reject only syntactically unsafe paths and preserve relative traversal. + + The remote scripts run with the saved workspace cwd, so ``../`` remains a + valid sandbox-relative path when callers need to reach sibling directories. + ``~`` and ``~/...`` are also accepted and resolved by the embedded scripts + against the shell layer's sandbox ``HOME``. + """ + normalized = (path or "").strip() if normalized in {"", ".", "./"}: if allow_current_directory: return "." raise SandboxFileError("invalid_sandbox_path", "path must not be blank", status_code=400) - if normalized.startswith("/") or normalized.startswith("~"): + if normalized.startswith("/"): raise SandboxFileError( "invalid_sandbox_path", "path must be relative to the sandbox workspace", status_code=400 ) + if normalized.startswith("~") and normalized != "~" and not normalized.startswith("~/"): + raise SandboxFileError("invalid_sandbox_path", "path must use ~ or ~/ for the sandbox home", status_code=400) if "\x00" in normalized or any(ord(ch) < 0x20 for ch in normalized): raise SandboxFileError("invalid_sandbox_path", "path contains unsupported control characters", status_code=400) return normalized diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py index c68142ce577..da541ca8cb0 100644 --- a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py +++ b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py @@ -5,6 +5,7 @@ from typing import cast from unittest.mock import patch import httpx +from graphon.model_runtime.entities.message_entities import TextPromptMessageContent from pydantic_ai.exceptions import ModelHTTPError, UserError from pydantic_ai.messages import ( InstructionPart, @@ -311,6 +312,62 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.usage.input_tokens, 11) self.assertEqual(response.usage.output_tokens, 7) + async def test_request_stream_splits_embedded_thinking_tags_from_text_content_parts(self) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return build_stream_response( + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=0, + message=AssistantPromptMessage( + content=[TextPromptMessageContent(data="beforereasoning")], + tool_calls=[], + ), + ), + ), + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=1, + message=AssistantPromptMessage( + content=[TextPromptMessageContent(data=" continuesafter")], + tool_calls=[], + ), + ), + ), + LLMResultChunk( + model="demo-model", + delta=LLMResultChunkDelta( + index=2, + message=AssistantPromptMessage(content="", tool_calls=[]), + usage=make_usage(prompt_tokens=6, completion_tokens=4), + finish_reason="stop", + ), + ), + ) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + async with adapter.request_stream( + [ModelRequest(parts=[UserPromptPart("hello")])], + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) as stream: + events = [event async for event in stream] + response = stream.get() + + self.assertTrue(events) + self.assertEqual([part.part_kind for part in response.parts], ["text", "thinking", "text"]) + self.assertEqual(cast(TextPart, response.parts[0]).content, "before") + self.assertEqual(cast(ThinkingPart, response.parts[1]).content, "reasoning continues") + self.assertEqual(cast(TextPart, response.parts[2]).content, "after") + async def test_request_stream_yields_response_parts_and_usage(self) -> None: def handler(_request: httpx.Request) -> httpx.Response: return build_stream_response( diff --git a/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py b/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py index dca0a9e7376..4fcb407555b 100644 --- a/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py +++ b/dify-agent/tests/local/dify_agent/adapters/shell/test_shellctl.py @@ -7,13 +7,14 @@ import base64 from collections.abc import Callable from dataclasses import dataclass, field +import httpx import pytest from dify_agent.adapters.shell import shellctl from dify_agent.adapters.shell.config import ShellAdapterSettings from dify_agent.adapters.shell.factory import create_shell_provider -from dify_agent.adapters.shell.protocols import ShellCommandResult -from dify_agent.adapters.shell.shellctl import ShellFileTransferError, ShellProviderError, ShellctlProvider +from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellProviderError +from dify_agent.adapters.shell.shellctl import ShellFileTransferError, ShellctlProvider @dataclass(slots=True) @@ -228,6 +229,82 @@ def test_commands_forward_parameters_and_map_metadata() -> None: assert client.delete_calls == [("run-job", True, 2.0)] +def test_commands_map_http_timeout_to_shell_provider_error() -> None: + request = httpx.Request("POST", "http://shellctl.example/v1/jobs") + client = FakeShellctlClient( + run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw( + httpx.ReadTimeout("timed out", request=request) + ) + ) + + async def scenario() -> None: + resource = await _provider(client).create() + with pytest.raises(ShellProviderError, match="timed out") as exc_info: + await resource.commands.run("pwd", timeout=2.5) + assert exc_info.value.code == "timeout" + + asyncio.run(scenario()) + + +def test_commands_map_http_request_error_to_shell_provider_error() -> None: + request = httpx.Request("POST", "http://shellctl.example/v1/jobs/run") + client = FakeShellctlClient( + wait_handler=lambda job_id, offset, timeout: (_ for _ in ()).throw( + httpx.ConnectError("connection failed", request=request) + ) + ) + + async def scenario() -> None: + resource = await _provider(client).create() + with pytest.raises(ShellProviderError, match="connection failed") as exc_info: + await resource.commands.wait("run-job", offset=3, timeout=4.0) + assert exc_info.value.code == "request_error" + + asyncio.run(scenario()) + + +def test_delete_maps_http_timeout_to_shell_provider_error() -> None: + request = httpx.Request("DELETE", "http://shellctl.example/v1/jobs/run-job") + + @dataclass(slots=True) + class DeleteTimeoutClient(FakeShellctlClient): + async def delete(self, job_id, *, force=False, grace_seconds=None): + self.delete_calls.append((job_id, force, grace_seconds)) + raise httpx.ReadTimeout("delete timed out", request=request) + + client = DeleteTimeoutClient() + + async def scenario() -> None: + resource = await _provider(client).create() + with pytest.raises(ShellProviderError, match="delete timed out") as exc_info: + await resource.commands.delete("run-job", force=True, grace_seconds=2.0) + assert exc_info.value.code == "timeout" + + asyncio.run(scenario()) + assert client.delete_calls == [("run-job", True, 2.0)] + + +def test_delete_maps_http_request_error_to_shell_provider_error() -> None: + request = httpx.Request("DELETE", "http://shellctl.example/v1/jobs/run-job") + + @dataclass(slots=True) + class DeleteRequestErrorClient(FakeShellctlClient): + async def delete(self, job_id, *, force=False, grace_seconds=None): + self.delete_calls.append((job_id, force, grace_seconds)) + raise httpx.ConnectError("delete connection failed", request=request) + + client = DeleteRequestErrorClient() + + async def scenario() -> None: + resource = await _provider(client).create() + with pytest.raises(ShellProviderError, match="delete connection failed") as exc_info: + await resource.commands.delete("run-job", force=True, grace_seconds=2.0) + assert exc_info.value.code == "request_error" + + asyncio.run(scenario()) + assert client.delete_calls == [("run-job", True, 2.0)] + + def test_files_upload_and_download_still_work() -> None: content = b"hello \x00 world" encoded = base64.b64encode(content).decode("ascii") diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_config.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_config.py index b9fbe015969..0032d9c6142 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_config.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_config.py @@ -77,34 +77,16 @@ def test_pull_config_files_from_environment_downloads_visible_files( assert (tmp_path / "guide.txt").read_bytes() == b"guide-bytes" -def test_pull_config_env_from_environment_writes_only_declared_keys( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload) - monkeypatch.setenv("API_KEY", "plain") - monkeypatch.setenv("JSON_VALUE", "two words") - - result = config_cli.pull_config_env_from_environment(local_path=str(tmp_path / ".env")) - - assert result.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n' - - -def test_pull_config_env_and_note_use_hidden_default_dir( +def test_pull_config_note_uses_hidden_default_dir( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.setattr(config_cli, "manifest_from_environment", _manifest_payload) - monkeypatch.setenv("API_KEY", "plain") - monkeypatch.setenv("JSON_VALUE", "two words") - env_path = config_cli.pull_config_env_from_environment() note_path = config_cli.pull_config_note_from_environment() - assert env_path == (tmp_path / ".dify_conf" / ".env").resolve() assert note_path == (tmp_path / ".dify_conf" / "note.md").resolve() - assert env_path.read_text(encoding="utf-8") == 'API_KEY=plain\nJSON_VALUE="two words"\n' assert note_path.read_text(encoding="utf-8") == "Use carefully." @@ -177,31 +159,6 @@ def test_push_config_note_from_environment_reads_stdin(monkeypatch: pytest.Monke assert request.note == "from-stdin" -def test_push_config_env_from_environment_reads_default_file( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.chdir(tmp_path) - env_path = tmp_path / ".dify_conf" / ".env" - env_path.parent.mkdir() - env_path.write_text("API_KEY=value\n", encoding="utf-8") - monkeypatch.setattr(config_cli, "read_agent_stub_environment", lambda: _environment()) - - captured: dict[str, object] = {} - - def fake_push_sync(**kwargs): - captured.update(kwargs) - return _manifest_payload() - - monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) - - config_cli.push_config_env_from_environment(None) - - request = cast(AgentStubConfigPushRequest, captured["request"]) - assert request.env_text == "API_KEY=value\n" - assert request.note is None - - def test_push_config_env_from_environment_reads_explicit_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -264,7 +221,7 @@ def test_push_config_files_from_environment_builds_upload_items( monkeypatch.setattr(config_cli, "request_agent_stub_config_push_sync", fake_push_sync) - config_cli.push_config_files_from_environment([str(file_path)], None) + config_cli.push_config_files_from_environment([str(file_path)]) request = cast(AgentStubConfigPushRequest, captured["request"]) assert request.files[0].name == "guide.txt" @@ -273,19 +230,9 @@ def test_push_config_files_from_environment_builds_upload_items( assert request.skills == [] -def test_push_config_files_from_environment_validates_name_usage(tmp_path: Path) -> None: - first = tmp_path / "a.txt" - second = tmp_path / "b.txt" - first.write_text("a", encoding="utf-8") - second.write_text("b", encoding="utf-8") - - with pytest.raises(AgentStubValidationError, match="--name requires exactly one PATH"): - config_cli.push_config_files_from_environment([str(first), str(second)], "renamed.txt") - - def test_push_config_files_from_environment_rejects_empty_paths() -> None: with pytest.raises(AgentStubValidationError, match="at least one file path is required"): - config_cli.push_config_files_from_environment([], None) + config_cli.push_config_files_from_environment([]) def test_delete_config_files_from_environment_builds_delete_items(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py index 0aedc6f435f..b87e79daa35 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py @@ -299,12 +299,11 @@ def test_cli_config_manifest_omits_hash_fields( "push_config_note_from_environment", {"local_path": "/tmp/note.md"}, ), - (["config", "env", "push"], "push_config_env_from_environment", {"local_path": None}), (["config", "env", "push", "/tmp/.env"], "push_config_env_from_environment", {"local_path": "/tmp/.env"}), ( - ["config", "files", "push", "/tmp/guide.txt", "--name", "runtime.txt"], + ["config", "files", "push", "/tmp/guide.txt"], "push_config_files_from_environment", - {"paths": ["/tmp/guide.txt"], "name": "runtime.txt"}, + {"paths": ["/tmp/guide.txt"]}, ), ( ["config", "files", "delete", "old.txt", "legacy.txt"], diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py index 3c713aedc34..164fd7487d7 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_files.py @@ -23,6 +23,7 @@ def _principal() -> AgentStubPrincipal: user_id="user-1", user_from="account", workflow_id="workflow-1", + conversation_id="conversation-1", agent_mode="workflow_run", invoke_from="service-api", ), @@ -54,6 +55,7 @@ def test_dify_api_agent_stub_file_handler_injects_execution_context_for_upload(m "user_id": "user-1", "filename": "report.pdf", "mimetype": "application/pdf", + "conversation_id": "conversation-1", } return httpx.Response(200, json={"data": {"url": "https://files.example.com/upload"}}) diff --git a/dify-agent/tests/local/dify_agent/client/test_client.py b/dify-agent/tests/local/dify_agent/client/test_client.py index d1b06ad5fc9..3a1525f015c 100644 --- a/dify-agent/tests/local/dify_agent/client/test_client.py +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -137,9 +137,9 @@ class DisconnectingSyncStream(httpx.SyncByteStream): def test_sse_decoder_accepts_function_tool_result_part_alias(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(client_module, "_FUNCTION_TOOL_RESULT_PAYLOAD_KEY", "result") + monkeypatch.setattr(client_module, "_function_tool_result_payload_key_cache", "part") decoder = client_module._SSEDecoder() - payload = _function_tool_result_payload("part") + payload = _function_tool_result_payload("result") assert decoder.feed_line(f"data: {json.dumps(payload)}") is None event = decoder.feed_line("") @@ -153,7 +153,7 @@ def test_sse_decoder_accepts_function_tool_result_part_alias(monkeypatch: pytest def test_function_tool_result_payload_normalization_supports_old_part_schema( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(client_module, "_FUNCTION_TOOL_RESULT_PAYLOAD_KEY", "part") + monkeypatch.setattr(client_module, "_function_tool_result_payload_key_cache", "part") payload = _function_tool_result_payload("result") normalized = client_module._normalize_run_event_payload_for_local_pydantic_ai(payload) diff --git a/dify-agent/tests/local/dify_agent/layers/conftest.py b/dify-agent/tests/local/dify_agent/layers/conftest.py deleted file mode 100644 index 9d393301962..00000000000 --- a/dify-agent/tests/local/dify_agent/layers/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -import sys -import types - -from agenton.layers import EmptyLayerConfig, EmptyRuntimeState, NoLayerDeps, PlainLayer -from pydantic import BaseModel - - -class _BaseSettings(BaseModel): - """Minimal test stub for environments without pydantic-settings installed.""" - - -class _SettingsConfigDict(dict[str, object]): - """Minimal callable mapping used by ShellAdapterSettings.model_config.""" - - -if "pydantic_settings" not in sys.modules: - stub = types.ModuleType("pydantic_settings") - setattr(stub, "BaseSettings", _BaseSettings) - setattr(stub, "SettingsConfigDict", _SettingsConfigDict) - sys.modules["pydantic_settings"] = stub - - -if "dify_agent.layers.execution_context.layer" not in sys.modules: - execution_context_stub = types.ModuleType("dify_agent.layers.execution_context.layer") - - class DifyExecutionContextLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, EmptyRuntimeState]): - """Minimal test stub for shell-layer type-only imports.""" - - setattr(execution_context_stub, "DifyExecutionContextLayer", DifyExecutionContextLayer) - sys.modules["dify_agent.layers.execution_context.layer"] = execution_context_stub diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py index a1cb137c10c..995fea0120c 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py @@ -1,5 +1,6 @@ import asyncio import json +from types import SimpleNamespace import httpx import pytest @@ -19,7 +20,7 @@ from dify_agent.layers.dify_plugin.configs import ( DifyPluginToolsLayerConfig, ) from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer -from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer +from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer, _PluginToolFileContext from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer @@ -91,6 +92,17 @@ def _execution_context_provider() -> LayerProvider[DifyExecutionContextLayer]: ) +def _tools_provider() -> LayerProvider[DifyPluginToolsLayer]: + return LayerProvider.from_factory( + layer_type=DifyPluginToolsLayer, + create=lambda config: DifyPluginToolsLayer.from_config_with_settings( + DifyPluginToolsLayerConfig.model_validate(config), + inner_api_url="http://dify-api", + inner_api_key="inner-secret", + ), + ) + + def _prepared_tool_parameters() -> list[DifyPluginToolParameter]: return [ DifyPluginToolParameter( @@ -151,6 +163,42 @@ def _llm_only_parameter(*, name: str, description: str, default: JsonValue = Non ) +def _file_parameter( + *, + name: str = "source", + parameter_type: DifyPluginToolParameterType = DifyPluginToolParameterType.FILE, +) -> DifyPluginToolParameter: + return DifyPluginToolParameter( + name=name, + type=parameter_type, + form=DifyPluginToolParameterForm.LLM, + required=True, + llm_description="Source file", + ) + + +def _file_tools_config( + *, + parameter_type: DifyPluginToolParameterType = DifyPluginToolParameterType.FILE, +) -> DifyPluginToolsLayerConfig: + return DifyPluginToolsLayerConfig( + tools=[ + DifyPluginToolConfig( + plugin_id="langgenius/tools", + provider="search", + tool_name="read_file", + credential_type="api-key", + parameters=[_file_parameter(parameter_type=parameter_type)], + parameters_json_schema={ + "type": "object", + "properties": {"source": {"type": "string"}}, + "required": ["source"], + }, + ) + ] + ) + + def _invoke_stream_response( *, error_payload: dict[str, object] | None = None, @@ -204,6 +252,28 @@ def _tool_transport( return httpx.MockTransport(handler) +def _file_tool_transport( + *, + expected_source: object, + download_response: dict[str, object] | None = None, +) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/inner/api/download/file/request"): + assert download_response is not None + payload = json.loads(request.content.decode("utf-8")) + assert payload["file"] == {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + return httpx.Response(200, json={"data": download_response}) + + if request.url.path.endswith("/dispatch/tool/invoke"): + payload = json.loads(request.content.decode("utf-8")) + assert payload["data"]["tool_parameters"]["source"] == expected_source + return _invoke_stream_response() + + raise AssertionError(f"Unexpected request path: {request.url.path}") + + return httpx.MockTransport(handler) + + def test_dify_plugin_type_id_constants_match_implementation_classes() -> None: assert DIFY_PLUGIN_LLM_LAYER_TYPE_ID == DifyPluginLLMLayer.type_id assert DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == DifyPluginToolsLayer.type_id @@ -245,7 +315,7 @@ def test_dify_plugin_tools_layer_uses_prepared_tool_definition_and_invokes_daemo compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient(transport=_tool_transport()) as client: @@ -253,7 +323,7 @@ def test_dify_plugin_tools_layer_uses_prepared_tool_definition_and_invokes_daemo configs={"execution_context": _execution_context_config(), "tools": _tools_config()} ) as run: tools_layer = run.get_layer("tools", DifyPluginToolsLayer) - tool = (await tools_layer.get_tools(http_client=client))[0] + tool = (await tools_layer.get_tools(http_client=client, dify_api_http_client=client))[0] tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType] result = await tool.function_schema.call( @@ -322,14 +392,16 @@ def test_dify_plugin_tools_layer_uses_each_tool_plugin_id_for_transport() -> Non compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with compositor.enter( configs={"execution_context": _execution_context_config(), "tools": tools_config} ) as run: - tools = await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client) + tools = await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) await tools[0].function_schema.call({"query": "first"}, None) # pyright: ignore[reportArgumentType] await tools[1].function_schema.call({"query": "second"}, None) # pyright: ignore[reportArgumentType] @@ -432,14 +504,18 @@ def test_dify_plugin_tools_layer_casts_prepared_parameter_values_before_invocati compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with compositor.enter( configs={"execution_context": _execution_context_config(), "tools": tools_config} ) as run: - tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) + )[0] result = await tool.function_schema.call( { @@ -496,14 +572,18 @@ def test_dify_plugin_tools_layer_sends_prepared_parameter_defaults_to_daemon() - compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: async with compositor.enter( configs={"execution_context": _execution_context_config(), "tools": tools_config} ) as run: - tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) + )[0] result = await tool.function_schema.call( {"query": "dify"}, @@ -515,12 +595,289 @@ def test_dify_plugin_tools_layer_sends_prepared_parameter_defaults_to_daemon() - asyncio.run(scenario()) +def test_dify_plugin_tools_layer_converts_remote_url_file_string() -> None: + async def scenario() -> None: + expected_source = { + "dify_model_identity": "__dify__file__", + "type": "custom", + "url": "https://example.com/report.pdf", + "filename": "report.pdf", + "mime_type": "application/pdf", + "extension": ".pdf", + "size": -1, + } + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=_file_tool_transport(expected_source=expected_source)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()} + ) as run: + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, + dify_api_http_client=client, + ) + )[0] + result = await tool.function_schema.call( + {"source": "https://example.com/report.pdf"}, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == 'found {"count": 1}' + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_converts_remote_url_file_mapping() -> None: + async def scenario() -> None: + expected_source = { + "dify_model_identity": "__dify__file__", + "type": "custom", + "url": "https://example.com/report.pdf", + "filename": "report.pdf", + "mime_type": "application/pdf", + "extension": ".pdf", + "size": -1, + } + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=_file_tool_transport(expected_source=expected_source)) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()} + ) as run: + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, + dify_api_http_client=client, + ) + )[0] + result = await tool.function_schema.call( + {"source": {"transfer_method": "remote_url", "url": "https://example.com/report.pdf"}}, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == 'found {"count": 1}' + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_resolves_tool_file_mapping_before_invocation() -> None: + async def scenario() -> None: + expected_source = { + "dify_model_identity": "__dify__file__", + "type": "custom", + "url": "https://signed.example/report.pdf", + "filename": "report.pdf", + "mime_type": "application/pdf", + "extension": ".pdf", + "size": 42, + } + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient( + transport=_file_tool_transport( + expected_source=expected_source, + download_response={ + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 42, + "download_url": "https://signed.example/report.pdf", + }, + ) + ) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()} + ) as run: + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, + dify_api_http_client=client, + ) + )[0] + result = await tool.function_schema.call( + {"source": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}}, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == 'found {"count": 1}' + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_converts_files_parameter_values() -> None: + async def scenario() -> None: + expected_source = [ + { + "dify_model_identity": "__dify__file__", + "type": "custom", + "url": "https://example.com/a.txt", + "filename": "a.txt", + "mime_type": "text/plain", + "extension": ".txt", + "size": -1, + }, + { + "dify_model_identity": "__dify__file__", + "type": "custom", + "url": "https://example.com/b.txt", + "filename": "b.txt", + "mime_type": "text/plain", + "extension": ".txt", + "size": -1, + }, + ] + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=_file_tool_transport(expected_source=expected_source)) as client: + async with compositor.enter( + configs={ + "execution_context": _execution_context_config(), + "tools": _file_tools_config(parameter_type=DifyPluginToolParameterType.FILES), + } + ) as run: + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, + dify_api_http_client=client, + ) + )[0] + result = await tool.function_schema.call( + {"source": ["https://example.com/a.txt", "https://example.com/b.txt"]}, + None, # pyright: ignore[reportArgumentType] + ) + + assert result == 'found {"count": 1}' + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_rejects_multiple_values_for_single_file() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(500))) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()} + ) as run: + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, + dify_api_http_client=client, + ) + )[0] + result = await tool.function_schema.call( + {"source": ["https://example.com/a.txt", "https://example.com/b.txt"]}, + None, # pyright: ignore[reportArgumentType] + ) + assert "only accepts one file" in result + + asyncio.run(scenario()) + + +def test_dify_plugin_tools_layer_rejects_path_without_shell() -> None: + async def scenario() -> None: + compositor = Compositor( + [ + LayerNode("execution_context", _execution_context_provider()), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(500))) as client: + async with compositor.enter( + configs={"execution_context": _execution_context_config(), "tools": _file_tools_config()} + ) as run: + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, + dify_api_http_client=client, + ) + )[0] + result = await tool.function_schema.call( + {"source": "outputs/report.pdf"}, + None, # pyright: ignore[reportArgumentType] + ) + assert "require an active shell layer" in result + + asyncio.run(scenario()) + + +def test_plugin_tool_file_context_uploads_sandbox_path_and_resolves_signed_url() -> None: + class FakeShell: + script: str | None = None + + async def run_remote_script_complete(self, script: str, **_kwargs: object) -> object: + self.script = script + return SimpleNamespace( + exit_code=0, + status="done", + output_complete=True, + output=( + "noise\n" + "<<>>" + '{"transfer_method":"tool_file","reference":"dify-file-ref:file-1"}' + "<<>>\n" + ), + ) + + class FakeFileClient: + async def request_download(self, **_kwargs: object) -> object: + return SimpleNamespace( + filename="report.pdf", + mime_type="application/pdf", + size=42, + download_url="https://signed.example/report.pdf", + ) + + async def scenario() -> None: + shell = FakeShell() + context = _PluginToolFileContext( + file_client=FakeFileClient(), # type: ignore[arg-type] + execution_context=_execution_context_config(), + shell=shell, # type: ignore[arg-type] + ) + result = await context.to_plugin_file_parameter("outputs/report.pdf") + + assert result == { + "dify_model_identity": "__dify__file__", + "type": "custom", + "url": "https://signed.example/report.pdf", + "filename": "report.pdf", + "mime_type": "application/pdf", + "extension": ".pdf", + "size": 42, + } + assert shell.script is not None + assert "outputs/report.pdf" in shell.script + + asyncio.run(scenario()) + + def test_dify_plugin_tools_layer_requires_hidden_runtime_parameters_in_prepared_config() -> None: async def scenario() -> None: compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient(transport=_tool_transport()) as client: @@ -531,7 +888,9 @@ def test_dify_plugin_tools_layer_requires_hidden_runtime_parameters_in_prepared_ } ) as run: with pytest.raises(ValueError, match="requires non-LLM runtime_parameters for: auth_scope"): - await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client) + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) asyncio.run(scenario()) @@ -541,7 +900,7 @@ def test_dify_plugin_tools_layer_returns_agent_friendly_error_text() -> None: compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient( @@ -555,7 +914,11 @@ def test_dify_plugin_tools_layer_returns_agent_friendly_error_text() -> None: async with compositor.enter( configs={"execution_context": _execution_context_config(), "tools": _tools_config()} ) as run: - tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) + )[0] result = await tool.function_schema.call( {"query": "dify", "region": "global"}, None, # pyright: ignore[reportArgumentType] @@ -571,7 +934,7 @@ def test_dify_plugin_tools_layer_propagates_unexpected_transport_errors() -> Non compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) @@ -585,7 +948,11 @@ def test_dify_plugin_tools_layer_propagates_unexpected_transport_errors() -> Non async with compositor.enter( configs={"execution_context": _execution_context_config(), "tools": _tools_config()} ) as run: - tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) + )[0] with pytest.raises(RuntimeError, match="unexpected transport failure"): await tool.function_schema.call( @@ -633,14 +1000,18 @@ def test_dify_plugin_tools_layer_maps_nested_plugin_invoke_errors_to_agent_text( compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient(transport=_tool_transport(invoke_error_payload=invoke_error_payload)) as client: async with compositor.enter( configs={"execution_context": _execution_context_config(), "tools": _tools_config()} ) as run: - tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) + )[0] result = await tool.function_schema.call( {"query": "dify", "region": "global"}, None, # pyright: ignore[reportArgumentType] @@ -656,14 +1027,18 @@ def test_dify_plugin_tools_layer_merges_blob_chunks_before_observation_conversio compositor = Compositor( [ LayerNode("execution_context", _execution_context_provider()), - LayerNode("tools", DifyPluginToolsLayer, deps={"execution_context": "execution_context"}), + LayerNode("tools", _tools_provider(), deps={"execution_context": "execution_context"}), ] ) async with httpx.AsyncClient(transport=_tool_transport(chunked_blob=True)) as client: async with compositor.enter( configs={"execution_context": _execution_context_config(), "tools": _tools_config()} ) as run: - tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0] + tool = ( + await run.get_layer("tools", DifyPluginToolsLayer).get_tools( + http_client=client, dify_api_http_client=client + ) + )[0] result = await tool.function_schema.call( {"query": "dify", "region": "global"}, None, # pyright: ignore[reportArgumentType] diff --git a/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py index cf41a884f4c..2c26f0dfb14 100644 --- a/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/execution_context/test_configs.py @@ -8,6 +8,7 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig def test_execution_context_package_exports_client_safe_config_symbols_only() -> None: assert execution_context_exports.__all__ == [ "DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID", + "DifyExecutionContextAgentConfigVersionKind", "DifyExecutionContextAgentMode", "DifyExecutionContextInvokeFrom", "DifyExecutionContextLayerConfig", diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index f4bf3fa8b99..f2a702139bb 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -9,7 +9,7 @@ from typing import cast import pytest import dify_agent.layers.shell.layer as shell_layer_module -from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig +from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellEnvVarConfig, DifyShellLayerConfig from dify_agent.layers.shell.layer import ( CompleteRemoteCommandResult, DEFAULT_TERMINATE_GRACE_SECONDS, @@ -20,9 +20,11 @@ from dify_agent.adapters.shell.protocols import ( ShellCommandResult, ShellCommandStatus, ShellFileTransferProtocol, + ShellProviderError, ShellProviderProtocol, ShellResourceProtocol, ) +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig def _command_result( @@ -82,6 +84,17 @@ def _assert_error_observation(result: object, *, job_id: str | None = None, incl assert includes in result["error"] +def _capture_logged_exceptions(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, tuple[object, ...]]]: + logged: list[tuple[str, tuple[object, ...]]] = [] + + def fake_exception(message: str, *args: object, **kwargs: object) -> None: + del kwargs + logged.append((message, args)) + + monkeypatch.setattr(shell_layer_module.logger, "exception", fake_exception) + return logged + + @dataclass(slots=True) class RunCall: script: str @@ -123,6 +136,10 @@ class DeleteCall: grace_seconds: float | None +class _UnexpectedToolError(Exception): + pass + + class FakeFiles(ShellFileTransferProtocol): async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None: raise AssertionError("resource.files should not be used by production shell layer logic") @@ -216,6 +233,43 @@ def _layer( return layer, provider +@dataclass(slots=True) +class _ExecutionContextStub: + config: DifyExecutionContextLayerConfig + + +def _bind_execution_context(layer: DifyShellLayer, *, agent_id: str | None = "agent-1") -> None: + layer.deps.execution_context = cast( + object, _ExecutionContextStub(config=_execution_context_config(agent_id=agent_id)) + ) + + +def _execution_context_config(*, agent_id: str | None = None) -> DifyExecutionContextLayerConfig: + return DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_id="user-1", + user_from="account", + agent_id=agent_id, + agent_mode="agent_app", + invoke_from="service-api", + ) + + +def _runtime_state( + *, + session_id: str = "abc12ff", + workspace_cwd: str = "~/workspace/abc12ff", + job_ids: list[str] | None = None, + job_offsets: dict[str, int] | None = None, +) -> DifyShellRuntimeState: + return DifyShellRuntimeState( + session_id=session_id, + workspace_cwd=workspace_cwd, + job_ids=[] if job_ids is None else job_ids, + job_offsets={} if job_offsets is None else job_offsets, + ) + + def test_shell_type_id_constant_matches_implementation_class() -> None: assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id @@ -235,13 +289,15 @@ def test_resource_context_calls_provider_create_and_resource_close() -> None: def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16)) monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff") + expected_home = "/home/agent-1" + expected_workspace_cwd = "/home/agent-1/workspace/abc12ff" def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: - assert env is None + assert env == {"HOME": expected_home} if cwd is None: assert 'mkdir -p "$HOME/workspace"' in script return _command_result("mkdir-job", status="exited", done=True, exit_code=0) - assert cwd == "~/workspace/abc12ff" + assert cwd == expected_workspace_cwd assert "apt-get install -y ripgrep" in script return _command_result("bootstrap-job", status="exited", done=True, exit_code=0) @@ -251,6 +307,7 @@ def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pyte cli_tools=[{"name": "ripgrep", "install_commands": ["apt-get install -y ripgrep"]}], ), ) + _bind_execution_context(layer) async def scenario() -> None: async with layer.resource_context(): @@ -259,13 +316,56 @@ def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pyte asyncio.run(scenario()) - assert layer.runtime_state == DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + assert layer.runtime_state.session_id == "abc12ff" + assert layer.runtime_state.workspace_cwd == "~/workspace/abc12ff" assert [call.job_id for call in provider.resource.commands.delete_calls] == ["mkdir-job", "bootstrap-job"] +def test_shell_layer_uses_agent_specific_home_and_workspace_cwd( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16)) + monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff") + + expected_home = "/home/agent-1" + expected_workspace_cwd = "/home/agent-1/workspace/abc12ff" + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del timeout + if script.startswith('mkdir -p "$HOME/workspace";'): + assert cwd is None + assert env == {"HOME": expected_home} + return _command_result("mkdir-job", status="exited", done=True, exit_code=0) + if script == "pwd": + assert cwd == expected_workspace_cwd + assert env == {"HOME": expected_home} + return _command_result("user-job", status="exited", done=True, exit_code=0, output=expected_home, offset=13) + raise AssertionError(f"Unexpected script: {script!r}") + + commands = FakeCommands(run_handler=run_handler) + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_create() + run_result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] + metadata, output = _parse_tagged_observation(run_result) + assert metadata["job_id"] == "user-job" + assert output == expected_home + + asyncio.run(scenario()) + + assert layer.runtime_state.session_id == "abc12ff" + assert layer.runtime_state.workspace_cwd == "~/workspace/abc12ff" + assert layer.runtime_state.job_ids == ["user-job"] + assert [call.job_id for call in commands.delete_calls] == ["mkdir-job"] + + def test_shell_layer_suspend_does_not_close_before_resource_context_exits() -> None: layer, provider = _layer(commands=FakeCommands()) - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -276,6 +376,46 @@ def test_shell_layer_suspend_does_not_close_before_resource_context_exits() -> N asyncio.run(scenario()) +def test_shell_layer_resume_recreates_live_home_and_workspace() -> None: + expected_home = "/home/agent-1" + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del timeout + assert script == 'mkdir -p "$HOME/workspace/abc12ff"' + assert cwd is None + assert env == {"HOME": expected_home} + return _command_result("resume-job", status="exited", done=True, exit_code=0) + + commands = FakeCommands(run_handler=run_handler) + layer, provider = _layer(commands=commands) + _bind_execution_context(layer) + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_resume() + + asyncio.run(scenario()) + + assert [call.job_id for call in commands.delete_calls] == ["resume-job"] + assert provider.resource.closed is True + + +def test_shell_layer_resume_requires_agent_id_for_live_workspace_reentry() -> None: + commands = FakeCommands() + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer, agent_id=None) + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + with pytest.raises(ValueError, match="requires execution_context\\.agent_id"): + await layer.on_context_resume() + + asyncio.run(scenario()) + assert commands.run_calls == [] + + def test_shell_layer_delete_cleans_workspace_and_tracked_jobs() -> None: def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: del env, timeout @@ -285,12 +425,8 @@ def test_shell_layer_delete_cleans_workspace_and_tracked_jobs() -> None: commands = FakeCommands(run_handler=run_handler) layer, provider = _layer(commands=commands) - layer.runtime_state = DifyShellRuntimeState( - session_id="abc12ff", - workspace_cwd="~/workspace/abc12ff", - job_ids=["user-job"], - job_offsets={"user-job": 9}, - ) + _bind_execution_context(layer) + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 9}) async def scenario() -> None: async with layer.resource_context(): @@ -307,8 +443,8 @@ def test_shell_layer_delete_cleans_workspace_and_tracked_jobs() -> None: def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> None: def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: assert script == "pwd" - assert cwd == "~/workspace/abc12ff" - assert env is None + assert cwd == "/home/agent-1/workspace/abc12ff" + assert env == {"HOME": "/home/agent-1"} return _command_result( "user-job", status="running", @@ -354,8 +490,9 @@ def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> No interrupt_handler=interrupt_handler, ) layer, provider = _layer(commands=commands) + _bind_execution_context(layer) tools = {tool.name: tool for tool in layer.tools} - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -410,8 +547,8 @@ def test_shell_layer_tools_map_inputs_and_maintain_offsets_with_tail_end() -> No def test_shell_run_keeps_original_offset_when_tail_lookup_fails_for_truncated_output() -> None: def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: assert script == "pwd" - assert cwd == "~/workspace/abc12ff" - assert env is None + assert cwd == "/home/agent-1/workspace/abc12ff" + assert env == {"HOME": "/home/agent-1"} return _command_result( "user-job", status="running", @@ -427,8 +564,9 @@ def test_shell_run_keeps_original_offset_when_tail_lookup_fails_for_truncated_ou commands = FakeCommands(run_handler=run_handler, tail_handler=tail_handler) layer, provider = _layer(commands=commands) + _bind_execution_context(layer) tools = {tool.name: tool for tool in layer.tools} - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -465,8 +603,9 @@ def test_shell_run_formats_large_non_truncated_output_without_tail_lookup() -> N ) ) layer, _provider = _layer(commands=commands) + _bind_execution_context(layer) tools = {tool.name: tool for tool in layer.tools} - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -488,12 +627,7 @@ def test_shell_interrupt_succeeds_when_tail_lookup_fails() -> None: ) layer, _provider = _layer(commands=commands) tools = {tool.name: tool for tool in layer.tools} - layer.runtime_state = DifyShellRuntimeState( - session_id="abc12ff", - workspace_cwd="~/workspace/abc12ff", - job_ids=["user-job"], - job_offsets={"user-job": 22}, - ) + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22}) async def scenario() -> None: async with layer.resource_context(): @@ -510,14 +644,270 @@ def test_shell_interrupt_succeeds_when_tail_lookup_fails() -> None: asyncio.run(scenario()) +def test_shell_run_returns_provider_timeout_error_observation_without_unexpected_logging( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw( + ShellProviderError("provider timed out", code="timeout") + ) + ) + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] + _assert_error_observation(result, includes="timeout") + + asyncio.run(scenario()) + assert logged == [] + + +def test_shell_wait_returns_provider_request_error_observation_with_job_id_and_no_unexpected_logging( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + wait_handler=lambda job_id, offset, timeout: (_ for _ in ()).throw( + ShellProviderError("provider unavailable", code="request_error") + ) + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 3}) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_wait"].function_schema.call( + {"job_id": "user-job", "timeout": 4.0}, + None, # pyright: ignore[reportArgumentType] + ) + _assert_error_observation(result, job_id="user-job", includes="request_error") + + asyncio.run(scenario()) + assert logged == [] + + +def test_shell_input_returns_provider_timeout_error_observation_with_job_id_and_no_unexpected_logging( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + input_handler=lambda job_id, text, offset, timeout: (_ for _ in ()).throw( + ShellProviderError("provider timed out", code="timeout") + ) + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 6}) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_input"].function_schema.call( + {"job_id": "user-job", "text": "ls\n", "timeout": 5.0}, + None, # pyright: ignore[reportArgumentType] + ) + _assert_error_observation(result, job_id="user-job", includes="timeout") + + asyncio.run(scenario()) + assert logged == [] + + +def test_shell_interrupt_returns_provider_request_error_observation_with_job_id_and_no_unexpected_logging( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + interrupt_handler=lambda job_id, grace_seconds: (_ for _ in ()).throw( + ShellProviderError("provider unavailable", code="request_error") + ) + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22}) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_interrupt"].function_schema.call( + {"job_id": "user-job", "grace_seconds": 1.5}, + None, # pyright: ignore[reportArgumentType] + ) + _assert_error_observation(result, job_id="user-job", includes="request_error") + + asyncio.run(scenario()) + assert logged == [] + + +def test_shell_run_returns_error_observation_and_logs_unexpected_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(_UnexpectedToolError("boom")) + ) + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] + _assert_error_observation(result, includes="boom") + + asyncio.run(scenario()) + assert logged == [ + ("Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", ("shell_run", "abc12ff", None)) + ] + + +def test_shell_wait_returns_error_observation_and_logs_unexpected_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + wait_handler=lambda job_id, offset, timeout: (_ for _ in ()).throw(_UnexpectedToolError("wait exploded")) + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 3}) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_wait"].function_schema.call( + {"job_id": "user-job", "timeout": 4.0}, + None, # pyright: ignore[reportArgumentType] + ) + _assert_error_observation(result, job_id="user-job", includes="wait exploded") + + asyncio.run(scenario()) + assert logged == [ + ("Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", ("shell_wait", "abc12ff", "user-job")) + ] + + +def test_shell_input_returns_error_observation_and_logs_unexpected_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + input_handler=lambda job_id, text, offset, timeout: (_ for _ in ()).throw( + _UnexpectedToolError("stdin exploded") + ) + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 6}) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_input"].function_schema.call( + {"job_id": "user-job", "text": "ls\n", "timeout": 5.0}, + None, # pyright: ignore[reportArgumentType] + ) + _assert_error_observation(result, job_id="user-job", includes="stdin exploded") + + asyncio.run(scenario()) + assert logged == [ + ( + "Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", + ("shell_input", "abc12ff", "user-job"), + ) + ] + + +def test_shell_interrupt_returns_error_observation_and_logs_unexpected_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + interrupt_handler=lambda job_id, grace_seconds: (_ for _ in ()).throw( + _UnexpectedToolError("interrupt exploded") + ) + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22}) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_interrupt"].function_schema.call( + {"job_id": "user-job", "grace_seconds": 1.5}, + None, # pyright: ignore[reportArgumentType] + ) + _assert_error_observation(result, job_id="user-job", includes="interrupt exploded") + + asyncio.run(scenario()) + assert logged == [ + ( + "Unexpected shell tool failure: tool=%s session_id=%s job_id=%s", + ("shell_interrupt", "abc12ff", "user-job"), + ) + ] + + +def test_shell_interrupt_logs_unexpected_tail_failure_but_still_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + logged = _capture_logged_exceptions(monkeypatch) + commands = FakeCommands( + interrupt_handler=lambda job_id, grace_seconds: _command_status(job_id, offset=22), + tail_handler=lambda job_id: (_ for _ in ()).throw(_UnexpectedToolError("tail exploded")), + ) + layer, _provider = _layer(commands=commands) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state(job_ids=["user-job"], job_offsets={"user-job": 22}) + + async def scenario() -> None: + async with layer.resource_context(): + result = await tools["shell_interrupt"].function_schema.call({"job_id": "user-job"}, None) # pyright: ignore[reportArgumentType] + metadata, output = _parse_tagged_observation(result) + assert metadata == { + "job_id": "user-job", + "status": "terminated", + "done": True, + "exit_code": 130, + } + assert output == "Job was interrupted." + + asyncio.run(scenario()) + assert logged == [ + ( + "Failed to fetch output path for interrupted shell job %s in session %s", + ("user-job", "abc12ff"), + ) + ] + + +def test_shell_run_propagates_cancelled_error() -> None: + commands = FakeCommands( + run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(asyncio.CancelledError()) + ) + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer) + tools = {tool.name: tool for tool in layer.tools} + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + with pytest.raises(asyncio.CancelledError): + await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] + + asyncio.run(scenario()) + + def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job() -> None: events: list[str] = [] def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: events.append("run") assert script == "printf 'abcdefghi'" - assert cwd == "~/workspace/abc12ff" - assert env is None + assert cwd == "/home/agent-1/workspace/abc12ff" + assert env == {"HOME": "/home/agent-1"} return _command_result("remote-job", status="running", done=False, output="abc", offset=3, truncated=True) def wait_handler(job_id: str, offset: int, timeout: float) -> ShellCommandResult: @@ -531,7 +921,8 @@ def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job commands = FakeCommands(run_handler=run_handler, wait_handler=wait_handler) layer, _provider = _layer(commands=commands) - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + _bind_execution_context(layer) + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -546,20 +937,126 @@ def test_run_remote_script_complete_uses_read_output_before_wait_and_deletes_job assert [call.job_id for call in commands.delete_calls] == ["remote-job"] +def test_run_remote_script_complete_uses_agent_specific_home_and_workspace_cwd() -> None: + expected_home = "/home/agent-1" + expected_workspace_cwd = "/home/agent-1/workspace/abc12ff" + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del timeout + assert script == "pwd" + assert cwd == expected_workspace_cwd + assert env == {"HOME": expected_home} + return _command_result( + "remote-job", + status="exited", + done=True, + exit_code=0, + output=expected_home, + offset=len(expected_home), + ) + + commands = FakeCommands(run_handler=run_handler) + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer) + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + result = await layer.run_remote_script_complete("pwd") + assert isinstance(result, CompleteRemoteCommandResult) + assert result.output == expected_home + + asyncio.run(scenario()) + assert [call.job_id for call in commands.delete_calls] == ["remote-job"] + + +def test_run_remote_script_complete_passes_config_env_values_to_shellctl_env() -> None: + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del timeout + assert script == "printenv API_TOKEN" + assert cwd == "/home/agent-1/workspace/abc12ff" + assert env == {"HOME": "/home/agent-1", "API_TOKEN": "inline-secret-value"} + return _command_result( + "remote-job", + status="exited", + done=True, + exit_code=0, + output="inline-secret-value\n", + offset=len("inline-secret-value\n"), + ) + + commands = FakeCommands(run_handler=run_handler) + layer, _provider = _layer( + commands=commands, + config=DifyShellLayerConfig(env=[DifyShellEnvVarConfig(name="API_TOKEN", value="inline-secret-value")]), + ) + _bind_execution_context(layer) + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + result = await layer.run_remote_script_complete("printenv API_TOKEN") + assert isinstance(result, CompleteRemoteCommandResult) + assert result.output == "inline-secret-value\n" + + asyncio.run(scenario()) + assert [call.job_id for call in commands.delete_calls] == ["remote-job"] + + +def test_run_remote_script_uses_agent_specific_home_and_workspace_cwd() -> None: + expected_home = "/home/agent-1" + expected_workspace_cwd = "/home/agent-1/workspace/abc12ff" + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del timeout + assert script == "pwd" + assert cwd == expected_workspace_cwd + assert env == {"HOME": expected_home} + return _command_result( + "remote-job", + status="exited", + done=True, + exit_code=0, + output=expected_home, + offset=len(expected_home), + ) + + commands = FakeCommands(run_handler=run_handler) + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer) + layer.runtime_state = _runtime_state() + + async def scenario() -> None: + async with layer.resource_context(): + result = await layer.run_remote_script("pwd") + assert isinstance(result, CompleteRemoteCommandResult) + assert result.output == expected_home + + asyncio.run(scenario()) + assert [call.job_id for call in commands.delete_calls] == ["remote-job"] + + def test_run_remote_script_complete_returns_incomplete_reason_for_output_limit() -> None: - commands = FakeCommands( - run_handler=lambda script, cwd, env, timeout: _command_result( + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: + del script, timeout + assert cwd == "/home/agent-1/workspace/abc12ff" + assert env == {"HOME": "/home/agent-1"} + return _command_result( "remote-job", status="running", done=False, output="hello world", offset=11, truncated=True, - ), + ) + + commands = FakeCommands( + run_handler=run_handler, interrupt_handler=lambda job_id, grace_seconds: _command_status(job_id, status="terminated", offset=11), ) layer, _provider = _layer(commands=commands) - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + _bind_execution_context(layer) + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -589,8 +1086,8 @@ def test_run_remote_script_complete_returns_incomplete_reason_for_timeout( def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult: nonlocal now assert script == "sleep 10" - assert cwd == "~/workspace/abc12ff" - assert env is None + assert cwd == "/home/agent-1/workspace/abc12ff" + assert env == {"HOME": "/home/agent-1"} assert timeout == pytest.approx(60.0, rel=0, abs=0.01) now = 161.0 return _command_result("remote-job", status="running", done=False, output="hello", offset=5) @@ -606,7 +1103,8 @@ def test_run_remote_script_complete_returns_incomplete_reason_for_timeout( ), ) layer, _provider = _layer(commands=commands) - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + _bind_execution_context(layer) + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -629,7 +1127,7 @@ def test_shell_layer_rejects_untracked_job_ids_without_provider_calls() -> None: commands = FakeCommands() layer, _provider = _layer(commands=commands) tools = {tool.name: tool for tool in layer.tools} - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer.runtime_state = _runtime_state() async def scenario() -> None: async with layer.resource_context(): @@ -649,10 +1147,26 @@ def test_shell_layer_rejects_untracked_job_ids_without_provider_calls() -> None: assert commands.interrupt_calls == [] +def test_shell_layer_requires_agent_id_for_live_command_execution(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(shell_layer_module.time, "time", lambda: int("abc12", 16)) + monkeypatch.setattr(shell_layer_module.secrets, "token_hex", lambda _nbytes: "ff") + commands = FakeCommands() + layer, _provider = _layer(commands=commands) + _bind_execution_context(layer, agent_id=None) + + async def scenario() -> None: + async with layer.resource_context(): + with pytest.raises(ValueError, match="requires execution_context\\.agent_id"): + await layer.on_context_create() + + asyncio.run(scenario()) + assert commands.run_calls == [] + + def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None: layer, _provider = _layer(commands=FakeCommands()) tools = {tool.name: tool for tool in layer.tools} - layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff") + layer.runtime_state = _runtime_state() async def scenario() -> None: result = await tools["shell_run"].function_schema.call({"script": "pwd"}, None) # pyright: ignore[reportArgumentType] diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py index b58e0818229..75fa8dda204 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -12,6 +12,7 @@ from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DIFY_PL from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID from dify_agent.protocol.schemas import ( + AgentRunUsage, RUN_EVENT_ADAPTER, CreateRunRequest, DeferredToolCallPayload, @@ -171,6 +172,7 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ "conversation_id": None, "agent_id": None, "agent_config_version_id": None, + "agent_config_version_kind": None, "agent_mode": "workflow_run", "invoke_from": "service-api", "trace_id": "trace-1", @@ -403,6 +405,27 @@ def test_run_succeeded_event_round_trips_explicit_json_null_output() -> None: assert b'"deferred_tool_call"' not in payload +def test_run_succeeded_event_round_trips_usage() -> None: + event = RunSucceededEvent( + run_id="run-usage", + data=RunSucceededEventData( + output="done", + session_snapshot=CompositorSessionSnapshot(layers=[]), + usage=AgentRunUsage(prompt_tokens=3, completion_tokens=5), + ), + ) + + payload = RUN_EVENT_ADAPTER.dump_json(event) + decoded = RUN_EVENT_ADAPTER.validate_json(payload) + + assert isinstance(decoded, RunSucceededEvent) + assert decoded.data.usage is not None + assert decoded.data.usage.prompt_tokens == 3 + assert decoded.data.usage.completion_tokens == 5 + assert decoded.data.usage.total_tokens == 8 + assert b'"usage"' in payload + + def test_on_exit_accept_layer_overrides() -> None: request = CreateRunRequest.model_validate( { diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index b9f5b5bf467..6c05cb65703 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -384,6 +384,8 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa terminal = sink.events["run-1"][-1] assert isinstance(terminal, RunSucceededEvent) assert terminal.data.output == "done" + assert terminal.data.usage is not None + assert terminal.data.usage.total_tokens > 0 assert [layer.name for layer in terminal.data.session_snapshot.layers] == [ "prompt", "renamed-execution-context", diff --git a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py index eae7f945bfb..880aa94cfa2 100644 --- a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py +++ b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py @@ -3,15 +3,19 @@ from __future__ import annotations import asyncio import base64 import json +import os +from pathlib import Path +import subprocess +import sys from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Literal +from typing import Literal, cast import pytest from agenton.compositor import CompositorSessionSnapshot, LayerProvider from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState -from dify_agent.adapters.shell.shellctl import ShellctlProvider +from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol, ShellctlProvider from dify_agent.agent_stub.server.shell_agent_stub_env import ( AGENT_STUB_API_BASE_URL_ENV_VAR, AGENT_STUB_AUTH_JWE_ENV_VAR, @@ -32,10 +36,13 @@ from dify_agent.protocol import ( build_sandbox_locator_from_run_request, ) from dify_agent.server.sandbox_files import ( + _LIST_SCRIPT, SandboxFileError, SandboxFileService, _OUTPUT_BEGIN, _OUTPUT_END, + _READ_SCRIPT, + _UPLOAD_SCRIPT, _decode_sandbox_payload, _shell_result_details, ) @@ -57,35 +64,35 @@ class _Job: class RunCall: script: str cwd: str | None - env: Mapping[str, str] | None + env: dict[str, str] | None timeout: float class FakeShellctlClient: - def __init__(self, *, run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job]) -> None: + def __init__(self, *, run_handler: Callable[[str, str | None, dict[str, str] | None, float], _Job]) -> None: self.run_handler = run_handler self.run_calls: list[RunCall] = [] self.delete_calls: list[str] = [] async def run( - self, script: str, *, cwd: str | None = None, env: Mapping[str, str] | None = None, timeout: float = 10.0 - ): + self, script: str, *, cwd: str | None = None, env: dict[str, str] | None = None, timeout: float = 10.0 + ) -> _Job: self.run_calls.append(RunCall(script=script, cwd=cwd, env=env, timeout=timeout)) return self.run_handler(script, cwd, env, timeout) - async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0): + async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> _Job: raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}") - async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0): + async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> _Job: raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}") - async def tail(self, job_id: str): + async def tail(self, job_id: str) -> _Job: raise AssertionError(f"Unexpected tail() call for {job_id}") - async def terminate(self, job_id: str, grace_seconds: float = 10.0): + async def terminate(self, job_id: str, grace_seconds: float = 10.0) -> _Job: raise AssertionError(f"Unexpected terminate() call for {job_id} grace={grace_seconds}") - async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None): + async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None) -> None: del force, grace_seconds self.delete_calls.append(job_id) return None @@ -125,6 +132,28 @@ def _complete_result( ) +def _run_embedded_script( + script_source: str, + *, + args: list[str], + cwd: Path, + env: Mapping[str, str] | None = None, +) -> dict[str, object]: + merged_env = dict(os.environ) + if env is not None: + merged_env.update(env) + completed = subprocess.run( + [sys.executable, "-", *args], + input=script_source, + text=True, + capture_output=True, + cwd=cwd, + env=merged_env, + check=False, + ) + return _decode_sandbox_payload(_complete_result(output=completed.stdout, exit_code=completed.returncode)) + + def _execution_context() -> DifyExecutionContextLayerConfig: return DifyExecutionContextLayerConfig( tenant_id="tenant-1", @@ -169,7 +198,7 @@ def _locator() -> SandboxLocator: def _service( - run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], _Job], + run_handler: Callable[[str, str | None, dict[str, str] | None, float], _Job], ) -> tuple[SandboxFileService, FakeShellctlClient]: client = FakeShellctlClient(run_handler=run_handler) execution_context_provider = LayerProvider.from_factory( @@ -187,7 +216,7 @@ def _service( shell_provider=ShellctlProvider( entrypoint="http://shellctl", token="", - client_factory=lambda: client, + client_factory=lambda: cast(ShellctlClientProtocol, cast(object, client)), ), agent_stub_api_base_url="https://agent.example.com/agent-stub", agent_stub_token_factory=lambda execution_context, *, session_id: ( @@ -198,6 +227,19 @@ def _service( return SandboxFileService(layer_providers=(execution_context_provider, shell_provider)), client +def _sandbox_python_run_call(client: FakeShellctlClient) -> RunCall: + for run_call in reversed(client.run_calls): + if run_call.script.startswith("python3 - "): + return run_call + raise AssertionError("sandbox python script was not executed") + + +def _sandbox_list_entries(payload: dict[str, object]) -> list[object]: + entries = payload.get("entries") + assert isinstance(entries, list) + return entries + + def test_list_files_runs_fixed_script_and_parses_response() -> None: service, client = _service( lambda script, cwd, env, timeout: _Job( @@ -215,13 +257,153 @@ def test_list_files_runs_fixed_script_and_parses_response() -> None: result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path="."))) assert result.entries[0].name == "notes.txt" - assert client.run_calls[0].cwd == "~/workspace/abc12ff" - assert client.run_calls[0].env is None - assert "python3 - . 1000 <<'PY'" in client.run_calls[0].script - assert client.delete_calls == ["sandbox-job"] + script_call = _sandbox_python_run_call(client) + assert script_call.cwd == "/home/agent-1/workspace/abc12ff" + assert script_call.env == {"HOME": "/home/agent-1"} + assert "python3 - . 1000 <<'PY'" in script_call.script + assert client.delete_calls[-1] == "sandbox-job" -@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~/secret-dir", "bad\x00path"]) +def test_list_files_allows_parent_relative_paths() -> None: + service, client = _service( + lambda script, cwd, env, timeout: _Job( + job_id="sandbox-job", + output=_wrap({"path": "../shared", "entries": [], "truncated": False}), + ) + ) + + result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path="../shared"))) + + assert result.path == "../shared" + assert "python3 - ../shared 1000 <<'PY'" in _sandbox_python_run_call(client).script + + +@pytest.mark.parametrize( + ("path", "expected_command"), + [ + ("~", "python3 - '~' 1000 <<'PY'"), + ("~/shared", "python3 - '~/shared' 1000 <<'PY'"), + ], +) +def test_list_files_allows_home_relative_paths(path: str, expected_command: str) -> None: + service, client = _service( + lambda script, cwd, env, timeout: _Job( + job_id="sandbox-job", + output=_wrap({"path": path, "entries": [], "truncated": False}), + ) + ) + + result = asyncio.run(service.list_files(SandboxListRequest(locator=_locator(), path=path))) + + assert result.path == path + assert expected_command in _sandbox_python_run_call(client).script + + +def test_embedded_scripts_allow_parent_relative_paths(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + cwd = workspace_dir / "run" + shared_dir = workspace_dir / "shared" + cwd.mkdir(parents=True) + shared_dir.mkdir() + notes_path = shared_dir / "notes.txt" + notes_path.write_text("hello", encoding="utf-8") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_dify_agent = bin_dir / "dify-agent" + fake_dify_agent.write_text( + "\n".join( + [ + "#!/usr/bin/env python3", + "import json", + "import sys", + 'if sys.argv[1:] != ["file", "upload", "../shared/notes.txt"]:', + ' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")', + 'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))', + ] + ) + + "\n", + encoding="utf-8", + ) + fake_dify_agent.chmod(0o755) + + list_payload = _run_embedded_script(_LIST_SCRIPT, args=["../shared", "1000"], cwd=cwd) + read_payload = _run_embedded_script(_READ_SCRIPT, args=["../shared/notes.txt", "8"], cwd=cwd) + upload_payload = _run_embedded_script( + _UPLOAD_SCRIPT, + args=["../shared/notes.txt"], + cwd=cwd, + env={"PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', os.defpath)}"}, + ) + + assert list_payload["path"] == "../shared" + assert any( + isinstance(entry, dict) and entry.get("name") == "notes.txt" for entry in _sandbox_list_entries(list_payload) + ) + assert read_payload == { + "path": "../shared/notes.txt", + "size": 5, + "truncated": False, + "binary": False, + "text": "hello", + } + assert upload_payload == { + "path": "../shared/notes.txt", + "file": {"transfer_method": "tool_file", "reference": "file-ref"}, + } + + +def test_embedded_scripts_expand_home_relative_paths(tmp_path: Path) -> None: + cwd = tmp_path / "workspace" / "run" + cwd.mkdir(parents=True) + home_dir = tmp_path / "home" / "agent-1" + shared_dir = home_dir / "shared" + shared_dir.mkdir(parents=True) + notes_path = shared_dir / "notes.txt" + notes_path.write_text("hello", encoding="utf-8") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake_dify_agent = bin_dir / "dify-agent" + fake_dify_agent.write_text( + "\n".join( + [ + "#!/usr/bin/env python3", + "import json", + "import sys", + 'if sys.argv[1:] != ["file", "upload", "~/shared/notes.txt"]:', + ' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")', + 'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))', + ] + ) + + "\n", + encoding="utf-8", + ) + fake_dify_agent.chmod(0o755) + + script_env = {"HOME": str(home_dir), "PATH": f"{bin_dir}{os.pathsep}{os.environ.get('PATH', os.defpath)}"} + list_payload = _run_embedded_script(_LIST_SCRIPT, args=["~/shared", "1000"], cwd=cwd, env=script_env) + read_payload = _run_embedded_script(_READ_SCRIPT, args=["~/shared/notes.txt", "8"], cwd=cwd, env=script_env) + upload_payload = _run_embedded_script(_UPLOAD_SCRIPT, args=["~/shared/notes.txt"], cwd=cwd, env=script_env) + + assert list_payload["path"] == "~/shared" + assert any( + isinstance(entry, dict) and entry.get("name") == "notes.txt" for entry in _sandbox_list_entries(list_payload) + ) + assert read_payload == { + "path": "~/shared/notes.txt", + "size": 5, + "truncated": False, + "binary": False, + "text": "hello", + } + assert upload_payload == { + "path": "~/shared/notes.txt", + "file": {"transfer_method": "tool_file", "reference": "file-ref"}, + } + + +@pytest.mark.parametrize("bad_path", ["/etc/passwd", "~other/secret-dir", "bad\x00path"]) def test_list_files_rejects_invalid_paths_before_shell_execution(bad_path: str) -> None: service, client = _service(lambda script, cwd, env, timeout: _Job(job_id="sandbox-job", output="unused")) @@ -262,8 +444,10 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None: assert result.file.transfer_method == "tool_file" assert result.file.reference == "file-ref" - assert client.run_calls[0].cwd == "~/workspace/abc12ff" - assert client.run_calls[0].env == { + script_call = _sandbox_python_run_call(client) + assert script_call.cwd == "/home/agent-1/workspace/abc12ff" + assert script_call.env == { + "HOME": "/home/agent-1", AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub", AGENT_STUB_AUTH_JWE_ENV_VAR: "token-for:tenant-1:abc12ff", AGENT_STUB_DRIVE_BASE_ENV_VAR: "/mnt/drive/agent-1", @@ -291,4 +475,38 @@ def test_read_file_uses_complete_mode_and_parses_response() -> None: result = asyncio.run(service.read_file(SandboxReadRequest(locator=_locator(), path="notes.txt", max_bytes=8))) assert result.text == "hello" - assert "python3 - notes.txt 8 <<'PY'" in client.run_calls[0].script + assert "python3 - notes.txt 8 <<'PY'" in _sandbox_python_run_call(client).script + + +@pytest.mark.parametrize( + ("sandbox_request", "expected_command"), + [ + (SandboxReadRequest(locator=_locator(), path="../notes.txt", max_bytes=8), "python3 - ../notes.txt 8 <<'PY'"), + (SandboxUploadRequest(locator=_locator(), path="../report.txt"), "python3 - ../report.txt <<'PY'"), + (SandboxReadRequest(locator=_locator(), path="~/notes.txt", max_bytes=8), "python3 - '~/notes.txt' 8 <<'PY'"), + (SandboxUploadRequest(locator=_locator(), path="~/report.txt"), "python3 - '~/report.txt' <<'PY'"), + ], +) +def test_read_and_upload_allow_relative_paths( + sandbox_request: SandboxReadRequest | SandboxUploadRequest, expected_command: str +) -> None: + expected_path = sandbox_request.path + service, client = _service( + lambda script, cwd, env, timeout: _Job( + job_id="sandbox-job", + output=_wrap( + {"path": expected_path, "size": 5, "truncated": False, "binary": False, "text": "hello"} + if isinstance(sandbox_request, SandboxReadRequest) + else {"path": expected_path, "file": {"transfer_method": "tool_file", "reference": "file-ref"}} + ), + ) + ) + + if isinstance(sandbox_request, SandboxReadRequest): + result = asyncio.run(service.read_file(sandbox_request)) + assert result.path == expected_path + else: + result = asyncio.run(service.upload_file(sandbox_request)) + assert result.path == expected_path + + assert expected_command in _sandbox_python_run_call(client).script diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index c24941fae7f..9a5e3b34a73 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -112,7 +112,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() -> assertions=[ "assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')", "assert dify_agent_layers_drive.__all__ == ['DIFY_DRIVE_LAYER_TYPE_ID', 'DifyDriveLayerConfig', 'DifyDriveSkillConfig']", - "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", + "assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentConfigVersionKind', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']", "assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']", "assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']", "assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeDatasetConfig', 'DifyKnowledgeEagerResult', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeQueryConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig', 'DifyKnowledgeRuntimeState', 'DifyKnowledgeSetConfig']", diff --git a/dify-agent/tests/local/test_packaging.py b/dify-agent/tests/local/test_packaging.py index c91ce3a7af4..610c4850127 100644 --- a/dify-agent/tests/local/test_packaging.py +++ b/dify-agent/tests/local/test_packaging.py @@ -9,7 +9,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2] CLIENT_SHARED_DTO_DEPENDENCIES = { "httpx==0.28.1", "pydantic>=2.12.5,<2.13", - "pydantic-ai-slim>=1.85.1,<2.0.0", + "pydantic-ai-slim>=1.102.0,<2.0.0", "typer>=0.16.1,<0.17", "typing-extensions>=4.12.2,<5.0.0", } @@ -23,7 +23,7 @@ SERVER_RUNTIME_DEPENDENCIES = { "pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0", "pydantic-settings>=2.12.0,<3.0.0", "redis>=7.4.0,<8.0.0", - "shell-session-manager==2.3.0", + "shell-session-manager==2.3.1", "uvicorn[standard]==0.46.0", } @@ -105,4 +105,5 @@ def test_local_sandbox_dockerfile_installs_stub_client_and_shellctl() -> None: assert "VIRTUAL_ENV" not in dockerfile assert "uv sync" not in dockerfile assert "mkdir -p /mnt/drive" in dockerfile + assert "chown dify:dify /home" in dockerfile assert '["shellctl", "serve", "--listen", "0.0.0.0:5004"]' in dockerfile diff --git a/dify-agent/uv.lock b/dify-agent/uv.lock index df40ea9fe1a..c0b7fa8897d 100644 --- a/dify-agent/uv.lock +++ b/dify-agent/uv.lock @@ -650,7 +650,7 @@ requires-dist = [ { name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" }, { name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" }, { name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" }, - { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.3.0" }, + { name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.4.0" }, { name = "typer", specifier = ">=0.16.1,<0.17" }, { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" }, @@ -3503,7 +3503,7 @@ wheels = [ [[package]] name = "shell-session-manager" -version = "2.3.0" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -3515,9 +3515,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/27/64b6086f54a700836c3fe6fc403e1a180a5c0285b528588663d0de951a6b/shell_session_manager-2.3.0.tar.gz", hash = "sha256:59598f4824623053405a6d0ba85c931e263597752aa6b91e67b0e523474dccf5", size = 51361, upload-time = "2026-06-28T18:32:57.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/5ad9b6a3f8a4ca78acd4fa7a44f55b3f41ce7d539d648d9beee30789dd5b/shell_session_manager-2.4.0.tar.gz", hash = "sha256:cffeff439a149fa0626dd6fe940cd2d010005d97b429769e607a2b09ae64cb84", size = 64571, upload-time = "2026-07-03T19:11:18.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/26/07e4815e630316f1840e36b686f91bd389940437e38b897a0231c487298f/shell_session_manager-2.3.0-py3-none-any.whl", hash = "sha256:7727cc328661d93dd40d6f42d2c6b46c95d49717698558e81d78b41a42501b4f", size = 48900, upload-time = "2026-06-28T18:32:55.937Z" }, + { url = "https://files.pythonhosted.org/packages/be/8c/d2f6bb7d461810c22d10b775aeec3b4374eec7fb9fa503d00653d45cd8b3/shell_session_manager-2.4.0-py3-none-any.whl", hash = "sha256:5bb2bb43def96b354817ba6a45ab88fd236897a07c7f6c94b53f3f0cf30fabe6", size = 56513, upload-time = "2026-07-03T19:11:17.506Z" }, ] [[package]] diff --git a/e2e/.env.example b/e2e/.env.example new file mode 100644 index 00000000000..530736a3546 --- /dev/null +++ b/e2e/.env.example @@ -0,0 +1,50 @@ +# Copy this file to e2e/.env.local for local runs. +# Do not commit e2e/.env.local. + +E2E_BASE_URL=http://127.0.0.1:3000 +E2E_API_URL=http://127.0.0.1:5001 + +E2E_STABLE_MODEL_PROVIDER=openai +E2E_STABLE_MODEL_NAME=gpt-5-nano +E2E_STABLE_MODEL_TYPE=llm + +E2E_AGENT_DECISION_MODEL_PROVIDER=openai +E2E_AGENT_DECISION_MODEL_NAME=gpt-5.5 +E2E_AGENT_DECISION_MODEL_TYPE=llm + +E2E_MODEL_PROVIDER_CREDENTIALS_JSON='{"openai_api_key":"replace-with-real-key"}' + +# Optional: external runtime CI/local profile. Defaults prepare Agent V2 runtime +# seed resources and then runs every @external-model/@external-tool scenario. +# E2E_EXTERNAL_RUNTIME_SEED_SPECS=agent-v2:external-runtime +# E2E_EXTERNAL_RUNTIME_TAGS=(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview + +# Optional: start the standalone dify-agent backend for Agent V2 runtime scenarios. +# This is required for scenarios tagged @agent-backend-runtime. It is separate +# from @external-model because other external-model scenarios may not use dify-agent. +# When enabled, the E2E runner also starts the shellctl local sandbox required by +# dify-agent's dify.shell/config runtime layer. +# E2E_START_AGENT_BACKEND=1 +# E2E_AGENT_BACKEND_PORT=5050 +# E2E_AGENT_BACKEND_URL=http://127.0.0.1:5050 +# E2E_SHELLCTL_PORT=5004 +# E2E_SHELLCTL_URL=http://127.0.0.1:5004 +# E2E_SHELLCTL_IMAGE=dify-agent-local-sandbox:e2e +# E2E_SHELLCTL_AUTH_TOKEN= + +# Optional: Agent V2 seed installs the latest OpenAI, JSON Process, and Tavily +# marketplace plugins by default. Override ids when testing a different provider +# set, or pin exact marketplace package identifiers for reproducible CI runs. +# E2E_MARKETPLACE_API_URL=https://marketplace.dify.ai +# E2E_MARKETPLACE_PLUGIN_IDS=langgenius/openai,langgenius/json_process,langgenius/tavily +# E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS= + +# Optional: required only for OAuth tool fixture scenarios. +# E2E_OAUTH_TOOL_CREDENTIAL_ID= +# E2E_OAUTH_TOOL_PROVIDER= +# E2E_OAUTH_TOOL_NAME= + +# Optional: required only for broken model recovery scenarios. +# E2E_BROKEN_MODEL_PROVIDER= +# E2E_BROKEN_MODEL_NAME=broken-model +# E2E_BROKEN_MODEL_TYPE=llm diff --git a/e2e/.gitignore b/e2e/.gitignore index 94517c8409d..a35ee3849b0 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -5,3 +5,6 @@ test-results/ cucumber-report/ .logs/ .generated-test-materials/ +seed-report/ +.env.local +.env.*.local diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index d80f6755bad..6f41c0e921e 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -53,6 +53,12 @@ pnpm -C e2e e2e:full # run a tagged subset pnpm -C e2e e2e -- --tags @smoke +# 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 @@ -74,8 +80,8 @@ flowchart TD C --> D["Cucumber loads config, steps, and support modules"] D --> E["BeforeAll bootstraps shared auth state via /install"] E --> F{"Which command is running?"} - F -->|`pnpm -C e2e e2e`| G["Run config default tags: not @fresh and not @skip and not @preview"] - F -->|`pnpm -C e2e e2e:full*`| H["Override tags to not @skip and not @preview"] + F -->|`pnpm -C e2e e2e`| G["Run config default tags: not @fresh and not @skip and not @preview and not @external-model and not @external-tool"] + F -->|`pnpm -C e2e e2e:full*`| H["Override tags to not @skip and not @preview and not @external-model and not @external-tool"] G --> I["Per-scenario BrowserContext from shared browser"] H --> I I --> J["Failure artifacts written to cucumber-report/artifacts"] @@ -105,7 +111,7 @@ Behavior depends on instance state: - uninitialized instance: completes install and stores authenticated state - initialized instance: signs in and reuses authenticated state -Because of that, the `@fresh` install scenario only runs in the `pnpm -C e2e e2e:full*` flows. The default `pnpm -C e2e e2e*` flows exclude `@fresh` and `@preview` via Cucumber config tags so they can be re-run against an already initialized instance while keeping Builder Preview scenarios opt-in. +Because of that, the `@fresh` install scenario only runs in the `pnpm -C e2e e2e:full*` flows. The default `pnpm -C e2e e2e*` flows exclude `@fresh`, `@preview`, `@external-model`, and `@external-tool` via Cucumber config tags so they can be re-run against an already initialized instance while keeping Builder Preview and real external runtime scenarios opt-in. Reset all persisted E2E state: @@ -201,8 +207,14 @@ Feature: Create dataset - `@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 - `@fresh` — only runs in `e2e:full` mode (requires uninitialized instance) +- `@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. - `@skip` — excluded from all runs +External runtime commands are opt-in. `pnpm -C e2e e2e:external:prepare` reads `E2E_EXTERNAL_RUNTIME_SEED_SPECS`, defaulting to `agent-v2:external-runtime`, and runs the matching seed packs before the external suite. `pnpm -C e2e e2e:external` reads `E2E_EXTERNAL_RUNTIME_TAGS`, defaulting to `(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview`. + +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 diff --git a/e2e/cucumber.config.ts b/e2e/cucumber.config.ts index 3e6bd722556..49d47641bca 100644 --- a/e2e/cucumber.config.ts +++ b/e2e/cucumber.config.ts @@ -1,8 +1,10 @@ import type { IConfiguration } from '@cucumber/cucumber' +import './scripts/env-register' const hasCliTags = process.argv.some(arg => arg === '--tags' || arg.startsWith('--tags=')) +const defaultNonExternalTags = 'not @fresh and not @skip and not @preview and not @external-model and not @external-tool' const defaultTags = process.env.E2E_CUCUMBER_TAGS - || (hasCliTags ? undefined : 'not @fresh and not @skip and not @preview') + || (hasCliTags ? undefined : defaultNonExternalTags) const config = { format: [ diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index 13d32647c11..6806fcac1d1 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -19,8 +19,8 @@ Use API setup for prerequisite state, then use Playwright only for user-observab Use tags in three layers: - Capability tags describe the product area: `@build`, `@files`, `@advanced-settings`, `@agent-edit`, `@publish`, `@access-point`, `@output-variables`, and similar tags. -- Execution-scope tags describe how the scenario should be selected: `@core`, `@infra`, `@web-app-runtime`, `@service-api-runtime`, `@preview`, and `@feature-gated`. -- Narrow fixture or sub-surface tags describe a specific dependency or slice: `@stable-model`, `@skill-fixture`, `@web-app-access`, `@workflow-reference`, `@files-limits`, and similar tags. +- Execution-scope tags describe how the scenario should be selected: `@core`, `@infra`, `@web-app-runtime`, `@service-api-runtime`, `@agent-backend-runtime`, `@preview`, `@feature-gated`, `@external-model`, and `@external-tool`. +- Narrow fixture or sub-surface tags describe a specific dependency or slice: `@stable-model`, `@agent-decision-model`, `@skill-fixture`, `@web-app-access`, `@workflow-reference`, `@files-limits`, and similar tags. - `@agent-v2` — required capability tag for all Agent v2 scenarios. - `@core` — stable non-runtime scenario expected to run in the regular Agent v2 suite when its explicit preconditions are met. Do not apply `@core` to Preview/Test Run, Web app chat runtime, or Backend service API chat runtime scenarios. @@ -28,7 +28,7 @@ Use tags in three layers: - `@build` — Build mode and Build draft behavior. - `@build-unavailable-resources` — feature-gated Build chat recovery when the user requests unavailable Skills or Tools. - `@files` — Files section upload, display, and fixture behavior. -- `@files-limits` — feature-gated file format, size, count, and in-progress upload limit behavior. +- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format, size, count, and in-progress upload recovery remain feature-gated until their product contracts are stable. - `@knowledge` — Knowledge Retrieval configuration display, persistence, and reference cleanup. - `@advanced-settings` — Env Editor, Content Moderation, and related Advanced Settings behavior. - `@agent-create` — Agent Roster creation and initial Configure navigation. @@ -36,17 +36,22 @@ Use tags in three layers: - `@publish` — publish and publish-bar state. - `@access-point` — Web app, Backend service API, and Workflow access surfaces. - `@stable-model` — active model fixture dependency. Apply this to every scenario that includes `the Agent Builder stable chat model is available` or otherwise requires an active model configured in the workspace. +- `@agent-decision-model` — stronger active model fixture dependency for scenarios that specifically validate Agent autonomous planning or resource-selection behavior, such as generated-query Knowledge Retrieval. Do not use it for scenarios that only need a model to exist or answer a deterministic prompt. - `@tool-fixture` — preseeded Tool dependency such as `JSON Process / JSON Replace` or `Tavily / Tavily Search`. - `@skill-fixture` — checked-in or preseeded Skill dependency such as `e2e-summary-skill`. - `@knowledge-fixture` — preseeded dataset dependency such as `E2E Agent Knowledge Base`. - `@full-config-agent` — fixed `E2E New Agent Builder Full Config` Agent dependency. - `@tool-states-agent` — fixed `E2E New Agent Builder Tool States` Agent dependency. +- `@oauth-tool-agent` — fixed `E2E Agent With OAuth Tool` Agent dependency for OAuth2 tool credential preservation. - `@file-tree-fixture` — fixed file-tree Agent drive/config-files dependency. - `@dual-retrieval-fixture` — fixed dual Knowledge Retrieval Agent dependency. - `@backend-api-access` — fixed or scenario-owned Backend service API access dependency. - `@published-web-app` — fixed or scenario-owned published Web app access dependency. - `@web-app-runtime` — published public Web app runtime behavior. Use it for scenarios that open the public Web app and assert real chat responses. Access Point URL, launch, customization, and settings surfaces remain `@access-point` behavior unless they send messages through the public Web app. - `@service-api-runtime` — Backend service API runtime behavior. Use it for scenarios that call the published service API and assert real chat responses. Endpoint display, copy, API key, and API reference surfaces remain `@access-point` behavior. +- `@agent-backend-runtime` — standalone `dify-agent` run-server dependency. Apply it only when the scenario goes through Agent v2 runtime execution that requires `AGENT_BACKEND_BASE_URL`, such as Build chat/finalize, published Web app chat, or Backend service API chat. This is separate from `@external-model`; a scenario can call a real model without using `dify-agent`, and a future Agent v2 runtime scenario might exercise `dify-agent` without a paid model provider. +- `@external-model` — package-wide external runtime tag. In Agent v2, apply it to Build chat generation/finalization, published Web app chat, Backend service API chat, and other Agent runtime requests that can invoke a real model provider. Do not use this for scenarios that only require a configured active model. +- `@external-tool` — package-wide external runtime tag. In Agent v2, apply it only when the scenario executes a real external tool provider at runtime. Do not use this for plugin installation, tool discovery, tool configuration persistence, or local deterministic tools. - `@feature-gated` — product capability is optional. This tag alone does not skip execution; the scenario must include an explicit step that returns `skipped` with a blocked-precondition reason when the feature is unavailable. Use feature-level `@core` only when every scenario in the file is stable, non-runtime, and not feature-gated. If a feature file mixes stable scenarios with runtime, Preview, or feature-gated scenarios, put `@core` only on the stable scenarios. Keep runtime tags scenario-level so the regular core suite cannot inherit them accidentally. @@ -107,6 +112,8 @@ Use `a basic configured Agent v2 test agent has been created via API` when a sce Use `a runnable Agent v2 test agent has been created via API` after `the Agent Builder stable chat model is available` when a scenario needs a real model-backed Agent. The step writes the preflight model into the Agent Soul model config through `features/agent-v2/support/agent-soul.ts` with deterministic E2E model settings; do not duplicate provider/model payload construction in individual steps. +Use `a runnable Agent v2 test agent using the agent-decision model has been created via API` after `the Agent Builder agent-decision chat model is available` only when the scenario is asserting Agent autonomous decision quality rather than generic runtime availability. Keep this scoped to scenarios tagged `@agent-decision-model`. + Use `the Agent v2 configuration should be saved automatically` after UI edits that rely on Configure autosave. It waits for the user-visible publish bar saved state; do not replace it with network-idle waits or internal store checks. API setup is acceptable for creating scenario-owned Agents, enabling Backend service API, writing composer drafts, seeding Build drafts, and preparing fixed state. The scenario must still assert user-visible behavior or a real persisted product contract through the public Console API. Do not assert only that a setup API call succeeded. @@ -153,7 +160,11 @@ Agent Builder preflight is read-only. It checks long-lived seed resources and re Treat preseeded Agent Builder resources as environment contracts. Preflight can report that a stable model, dataset, Skill, Tool credential, fixed Agent, published Web app, or workflow reference is missing, inactive, not indexed, or drifted, but it must not repair that drift during a scenario. Seed scripts, CI bootstrap, or the documented environment maintenance flow own creating and keeping those resources valid. -Use `the Agent Builder stable chat model is available` before scenarios that need a real Agent Soul model configuration. This includes true runtime scenarios, model-backed build-mode assertions, and Workflow Agent v2 node setup because the backend rejects Agent nodes without model config. Do not add the model preflight to pure navigation or identity checks unless the setup API itself requires model config. `E2E_STABLE_MODEL_PROVIDER`, `E2E_STABLE_MODEL_NAME`, and optional `E2E_STABLE_MODEL_TYPE` are selectors for a model already configured in the workspace; they are not provider credentials. The step defaults to `openai` / `gpt-5.4-mini` / `llm`, verifies the selected model is present and `active` through `/console/api/workspaces/current/models/model-types/{type}`, then stores it on `DifyWorld.agentBuilder.preflight.stableModel`. +Use `the Agent v2 runtime backend is available` before scenarios tagged `@agent-backend-runtime`. The step checks the standalone `dify-agent` run server through `E2E_AGENT_BACKEND_URL`, `AGENT_BACKEND_BASE_URL`, or the default `http://127.0.0.1:5050` when `E2E_START_AGENT_BACKEND=1`. The E2E runner starts this service with `uv run --project dify-agent --extra server uvicorn dify_agent.server.app:app --host 127.0.0.1 --port 5050` only when `E2E_START_AGENT_BACKEND=1` is explicit. Because Agent App runtime always carries the `dify.config` layer and that layer depends on `dify.shell`, the same runner path also starts the `dify-agent/docker/local-sandbox` shellctl container on `E2E_SHELLCTL_PORT` (default `5004`) and injects `DIFY_AGENT_SHELLCTL_ENTRYPOINT` into the Agent backend. Missing or unreachable runtime backend or shellctl sandbox should be reported as a blocked precondition, not discovered later as a `/build-chat/finalize` 400 or Web app response timeout. + +Use `the Agent Builder stable chat model is available` before scenarios that need a real Agent Soul model configuration. This includes true runtime scenarios, model-backed build-mode assertions, and Workflow Agent v2 node setup because the backend rejects Agent nodes without model config. Do not add the model preflight to pure navigation or identity checks unless the setup API itself requires model config. `E2E_STABLE_MODEL_PROVIDER`, `E2E_STABLE_MODEL_NAME`, and optional `E2E_STABLE_MODEL_TYPE` are selectors for a model already configured in the workspace; they are not provider credentials. The step defaults to `openai` / `gpt-5-nano` / `llm`, verifies the selected model is present and `active` through `/console/api/workspaces/current/models/model-types/{type}`, then stores it on `DifyWorld.agentBuilder.preflight.stableModel`. + +Use `the Agent Builder agent-decision chat model is available` before scenarios that need a stronger model to exercise Agent autonomous planning, generated query selection, or tool/resource choice. `E2E_AGENT_DECISION_MODEL_PROVIDER`, `E2E_AGENT_DECISION_MODEL_NAME`, and optional `E2E_AGENT_DECISION_MODEL_TYPE` are selectors for a second active model fixture, defaulting to `openai` / `gpt-5.5` / `llm`. The step stores the model on `DifyWorld.agentBuilder.preflight.agentDecisionModel`. Do not use this fixture as a broad replacement for `@stable-model`; it is intentionally narrower and costlier. Keep `@stable-model` on Build draft apply scenarios that click `Apply`. The current product path calls `/build-chat/finalize` before applying the draft, and the backend returns `model is required` when the Agent Soul has no model config. Discard-only and pending-draft isolation scenarios can stay model-free when they do not finalize the Build draft. @@ -163,8 +174,12 @@ Override the default selector only when a scenario or environment explicitly nee ```bash E2E_STABLE_MODEL_PROVIDER=openai -E2E_STABLE_MODEL_NAME=gpt-5.4-mini +E2E_STABLE_MODEL_NAME=gpt-5-nano E2E_STABLE_MODEL_TYPE=llm + +E2E_AGENT_DECISION_MODEL_PROVIDER=openai +E2E_AGENT_DECISION_MODEL_NAME=gpt-5.5 +E2E_AGENT_DECISION_MODEL_TYPE=llm ``` Dify may expose OpenAI as either `openai` or a plugin provider ID such as `langgenius/openai/openai`. The preflight accepts both forms for selection and stores the actual Console API provider ID for Agent Soul setup. @@ -187,6 +202,8 @@ Use `the Agent Builder preseeded Agent "{agent}" includes the core fixture confi Use `the Agent Builder preseeded Agent "{agent}" includes the tool state fixture configuration` for the fixed Tool States Agent prerequisite. It composes the Summary Skill, JSON Replace tool, and Tavily Search tool preflights, then reads `/console/api/agent/{agent_id}/composer` to verify the Agent Soul includes JSON Replace, Tavily Search, and a Tavily credential reference. This proves the seed is configured to exercise tool status UI; keep actual invalid-credential errors in dependent user-visible configuration or runtime scenarios. +Use `the Agent Builder preseeded Agent "{agent}" includes an OAuth2 tool credential` for the fixed OAuth Tool Agent prerequisite. It reads the Agent composer and verifies at least one Dify tool has `credential_type: oauth2` with a `credential_ref.id`. Scenarios that need to save or mutate this configuration should copy that tool config into a scenario-owned Agent instead of mutating the fixed preseeded Agent. + Use `the Agent Builder preseeded Agent "{agent}" includes the dual retrieval fixture configuration` for the fixed Dual Retrieval Agent prerequisite. It composes the indexed knowledge-base preflight, then reads `/console/api/agent/{agent_id}/composer` to verify `agent_soul.knowledge.sets` includes both an Agent-decide generated query set and a custom user-query set using the fixed custom query. Use `the Agent Builder preseeded Agent "{agent}" includes the file tree fixture files` for file-tree display prerequisites. It verifies the Agent drive contains every file from `agentBuilderFileTreeFixtureFiles` through `/console/api/agent/{agent_id}/drive/files?prefix=files/`. @@ -213,6 +230,6 @@ Order blocked steps by the real owner of the first unresolved condition. If a sc Use partial coverage only when current product behavior is intentionally narrower than the written requirement and the test still asserts a real user-visible behavior. Example: Files are currently flat in Agent config files, so the flat Files list can be asserted while tree display remains blocked until product support exists. -File format, size, count, and in-progress upload limit cases are feature-gated until the product exposes stable Agent config file restrictions and user-visible recovery/error states. Do not convert `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration. +Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format, size, count, and in-progress upload limit cases remain feature-gated until the product exposes stable Agent config file restrictions and user-visible recovery/error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration. Do not mark a scenario as complete if it only proves setup state and does not assert the user-visible behavior or persisted product contract required by the case. diff --git a/e2e/features/agent-v2/access-point.feature b/e2e/features/agent-v2/access-point.feature index 877776dc0cf..431133e50ac 100644 --- a/e2e/features/agent-v2/access-point.feature +++ b/e2e/features/agent-v2/access-point.feature @@ -162,10 +162,11 @@ Feature: Agent v2 Access Point When I refresh the current page Then Agent v2 Backend service API access should be in service - @service-api-runtime @backend-api-access @stable-model + @service-api-runtime @external-model @agent-backend-runtime @backend-api-access @stable-model Scenario: Published Agent v2 answers through Backend service API Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And Agent v2 Backend service API access has been enabled with a key via API When I open the Agent v2 configure page @@ -174,10 +175,11 @@ Feature: Agent v2 Access Point When I send the Agent v2 Backend service API minimal request Then the Agent v2 Backend service API request should succeed with the normal E2E marker - @service-api-runtime @backend-api-access @stable-model + @service-api-runtime @external-model @agent-backend-runtime @backend-api-access @stable-model Scenario: Disabled Backend service API rejects requests and restored access succeeds Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And the Agent v2 draft has been published via API And Agent v2 Backend service API access has been enabled with a key via API diff --git a/e2e/features/agent-v2/build-draft.feature b/e2e/features/agent-v2/build-draft.feature index c36f9245280..9ef9cc3ab17 100644 --- a/e2e/features/agent-v2/build-draft.feature +++ b/e2e/features/agent-v2/build-draft.feature @@ -1,19 +1,17 @@ @agent-v2 @authenticated @build Feature: Agent v2 build draft - @core @stable-model @tool-fixture @skill-fixture + @external-model @agent-backend-runtime @stable-model Scenario: Generating a Build draft leaves the normal Agent configuration unchanged Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available - And the Agent Builder preseeded tool "JSON Process / JSON Replace" is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API - And the e2e-summary-skill Skill is available to the Agent v2 test agent When I open the Agent v2 configure page And I generate an Agent v2 Build draft from the fixed instruction Then I should see the Agent v2 Build draft pending changes And I should see the Agent v2 Build mode confirmation state And the normal Agent v2 draft should still use the normal E2E prompt And the normal Agent v2 draft should not include the e2e-summary-skill Skill - And the normal Agent v2 draft should not include the Agent Builder JSON Replace tool @core Scenario: Discarding a Build draft keeps the original Agent configuration @@ -58,10 +56,11 @@ Feature: Agent v2 build draft And the Agent v2 draft should not include the supported Build draft config And the Agent v2 Build draft should no longer be active - @core @stable-model + @external-model @agent-backend-runtime @stable-model Scenario: Applying a pending Build draft updates the normal Agent configuration Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And an Agent v2 Build draft uses the updated E2E prompt with the stable E2E model When I open the Agent v2 configure page @@ -76,10 +75,11 @@ Feature: Agent v2 build draft Then I should see the updated E2E prompt in the Agent v2 prompt editor And the Agent v2 Build draft should no longer be active - @core @stable-model @skill-fixture + @external-model @agent-backend-runtime @stable-model @skill-fixture Scenario: Applying a Build draft updates supported configuration sections Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And an Agent v2 Build draft adds the supported E2E files, skills, and env When I open the Agent v2 configure page @@ -103,10 +103,11 @@ Feature: Agent v2 build draft And I should see the supported E2E environment variable in Advanced Settings And the Agent v2 Build draft should no longer be active - @core @stable-model @skill-fixture + @external-model @agent-backend-runtime @stable-model @skill-fixture Scenario: Applying a Build draft with an existing Skill keeps a single Skill entry Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And an Agent v2 Build draft includes the existing e2e-summary-skill Skill When I open the Agent v2 configure page diff --git a/e2e/features/agent-v2/files.feature b/e2e/features/agent-v2/files.feature index 6b4fb44851a..700f90d2d30 100644 --- a/e2e/features/agent-v2/files.feature +++ b/e2e/features/agent-v2/files.feature @@ -52,13 +52,14 @@ Feature: Agent v2 files When I open the Agent v2 configure page Then Agent v2 oversized file rejection should be available - @files-limits @feature-gated - Scenario: Agent v2 single-batch file count limits are enforced + @core @files-limits + Scenario: Dropping multiple Agent v2 files at once is rejected Given I am signed in as the default E2E admin - And Agent v2 single-batch file count limits are available And a basic configured Agent v2 test agent has been created via API When I open the Agent v2 configure page - Then Agent v2 single-batch file count limits should be available + And I drop multiple Agent v2 files into the Files upload dialog + Then the Agent v2 Files upload dialog should reject the multiple-file drop + And I should not see the dropped Agent v2 files in the Files section @files-limits @feature-gated Scenario: Agent v2 total file count limits are enforced diff --git a/e2e/features/agent-v2/knowledge.feature b/e2e/features/agent-v2/knowledge.feature index 8e111418b9c..494f8f367db 100644 --- a/e2e/features/agent-v2/knowledge.feature +++ b/e2e/features/agent-v2/knowledge.feature @@ -24,12 +24,13 @@ Feature: Agent v2 Knowledge Retrieval When I refresh the current page Then I should see the Agent v2 Custom query Knowledge Retrieval settings - @service-api-runtime @stable-model @backend-api-access + @service-api-runtime @external-model @agent-backend-runtime @agent-decision-model @backend-api-access Scenario: Agent decide Knowledge Retrieval answers through Backend service API Given I am signed in as the default E2E admin - And the Agent Builder stable chat model is available + And the Agent Builder agent-decision chat model is available + And the Agent v2 runtime backend is available And the Agent Builder preseeded dataset "E2E Agent Knowledge Base" is indexed and ready - And a runnable Agent v2 test agent has been created via API + And a runnable Agent v2 test agent using the agent-decision model has been created via API And Agent v2 Backend service API access has been enabled with a key via API When I open the Agent v2 configure page And I add the Agent Builder knowledge base as an Agent decide Knowledge Retrieval @@ -40,10 +41,11 @@ Feature: Agent v2 Knowledge Retrieval When I send the Agent v2 Backend service API knowledge request Then the Agent v2 Backend service API response should include the knowledge E2E marker - @service-api-runtime @stable-model @backend-api-access + @service-api-runtime @external-model @agent-backend-runtime @stable-model @backend-api-access Scenario: Custom query Knowledge Retrieval answers through Backend service API Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And the Agent Builder preseeded dataset "E2E Agent Knowledge Base" is indexed and ready And a runnable Agent v2 test agent has been created via API And Agent v2 Backend service API access has been enabled with a key via API diff --git a/e2e/features/agent-v2/preflight.feature b/e2e/features/agent-v2/preflight.feature index 54b19d813dc..1593fd758aa 100644 --- a/e2e/features/agent-v2/preflight.feature +++ b/e2e/features/agent-v2/preflight.feature @@ -14,6 +14,11 @@ Feature: Agent Builder preseeded environment Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + @agent-decision-model + Scenario: Agent-decision chat model is available + Given I am signed in as the default E2E admin + And the Agent Builder agent-decision chat model is available + @broken-model Scenario: Broken chat model is available for recovery scenarios Given I am signed in as the default E2E admin @@ -79,6 +84,11 @@ Feature: Agent Builder preseeded environment Given I am signed in as the default E2E admin And the Agent Builder preseeded Agent "E2E New Agent Builder Tool States" includes the tool state fixture configuration + @oauth-tool-agent + Scenario: OAuth2 tool Agent includes credential fixture configuration + Given I am signed in as the default E2E admin + And the Agent Builder preseeded Agent "E2E Agent With OAuth Tool" includes an OAuth2 tool credential + @file-tree-fixture Scenario: File tree Agent includes fixture files Given I am signed in as the default E2E admin diff --git a/e2e/features/agent-v2/publish.feature b/e2e/features/agent-v2/publish.feature index 746b3c69965..d2a0bd1547a 100644 --- a/e2e/features/agent-v2/publish.feature +++ b/e2e/features/agent-v2/publish.feature @@ -58,10 +58,11 @@ Feature: Agent v2 publish And the normal Agent v2 draft should use the normal E2E prompt And the Agent v2 publish action should be available for unpublished changes - @web-app-runtime @published-web-app @stable-model + @web-app-runtime @external-model @agent-backend-runtime @published-web-app @stable-model Scenario: Published Agent v2 answers through Web app Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page @@ -72,10 +73,11 @@ Feature: Agent v2 publish Then the Agent v2 Web app response should include the normal E2E marker When I close the Agent v2 Web app - @web-app-runtime @published-web-app @stable-model + @web-app-runtime @external-model @agent-backend-runtime @published-web-app @stable-model Scenario: Published Web app remains isolated from unpublished Agent v2 draft edits Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page @@ -90,10 +92,11 @@ Feature: Agent v2 publish And the Agent v2 Web app response should not include the updated E2E marker When I close the Agent v2 Web app - @web-app-runtime @published-web-app @stable-model + @web-app-runtime @external-model @agent-backend-runtime @published-web-app @stable-model Scenario: Published Web app uses the latest Agent v2 published configuration Given I am signed in as the default E2E admin And the Agent Builder stable chat model is available + And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index d8449a673c6..272364ba192 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -6,6 +6,7 @@ import type { ChatRequestPayloadWithUser, PostChatMessagesResponse, } from '@dify/contracts/api/service/types.gen' +import type { APIResponse } from '@playwright/test' import { request } from '@playwright/test' import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from '../../../support/api' import { getTestAgent } from './agent' @@ -16,6 +17,84 @@ export type AgentServiceApiChatResult = { status: number } +type ServiceApiSseEvent = { + data: unknown + event?: string +} + +async function parseServiceApiChatResponse(response: APIResponse) { + const contentType = response.headers()['content-type'] ?? '' + const text = await response.text().catch(() => '') + + if (contentType.includes('text/event-stream')) + return parseServiceApiSseText(text) + + if (contentType.includes('application/json')) { + try { + return JSON.parse(text) as unknown + } + catch { + return { message: text } + } + } + + try { + return JSON.parse(text) as unknown + } + catch { + return { message: text } + } +} + +function parseServiceApiSseText(text: string) { + const events: ServiceApiSseEvent[] = [] + const answers: string[] = [] + + for (const block of text.split(/\r?\n\r?\n/)) { + const lines = block.split(/\r?\n/) + const eventName = lines + .find(line => line.startsWith('event:')) + ?.slice('event:'.length) + .trim() + const dataText = lines + .filter(line => line.startsWith('data:')) + .map(line => line.slice('data:'.length).trimStart()) + .join('\n') + + if (!dataText) + continue + + let data: unknown = dataText + try { + data = JSON.parse(dataText) as unknown + } + catch { + data = dataText + } + + events.push({ + data, + ...(eventName ? { event: eventName } : {}), + }) + + if ( + data + && typeof data === 'object' + && !Array.isArray(data) + && 'answer' in data + && typeof data.answer === 'string' + ) { + answers.push(data.answer) + } + } + + return { + answer: answers.join(''), + events, + raw: text, + } +} + export async function setAgentSiteAccessAndGetURL( agentId: string, enabled: boolean, @@ -77,7 +156,7 @@ export async function sendAgentServiceApiChatMessage({ const body = { inputs: {}, query, - response_mode: 'blocking', + response_mode: 'streaming', user: 'e2e-agent-access-point', } satisfies ChatRequestPayloadWithUser @@ -88,9 +167,7 @@ export async function sendAgentServiceApiChatMessage({ Authorization: `Bearer ${apiKey}`, }, }) - const responseBody = await response.json().catch(async () => ({ - message: await response.text().catch(() => ''), - })) + const responseBody = await parseServiceApiChatResponse(response) return { body: responseBody as PostChatMessagesResponse | unknown, diff --git a/e2e/features/agent-v2/support/agent-builder-resources.ts b/e2e/features/agent-v2/support/agent-builder-resources.ts index 0b6a70853a3..1ce25f836a5 100644 --- a/e2e/features/agent-v2/support/agent-builder-resources.ts +++ b/e2e/features/agent-v2/support/agent-builder-resources.ts @@ -5,10 +5,12 @@ export const agentBuilderPreseededResources = { tavilySearchTool: 'Tavily / Tavily Search', agentKnowledgeBase: 'E2E Agent Knowledge Base', indexingKnowledgeBase: 'E2E Agent Knowledge Base Indexing', + agentDecisionChatModel: 'E2E Agent Decision Chat Model', brokenModelProvider: 'E2E Broken Model Provider', brokenModel: 'e2e-broken-model', fullConfigAgent: 'E2E New Agent Builder Full Config', toolStatesAgent: 'E2E New Agent Builder Tool States', + oauthToolAgent: 'E2E Agent With OAuth Tool', fileTreeAgent: 'E2E Agent With File Tree', dualRetrievalAgent: 'E2E Agent With Dual Retrieval', publishedWebAppAgent: 'E2E Agent Published Web App', @@ -23,7 +25,8 @@ export const agentBuilderFixedInputs = { missingSkillSearch: 'E2E_NOT_EXIST_SKILL', missingToolSearch: 'E2E_NOT_EXIST_TOOL', missingToolSearchWithSuffix: 'E2E_NOT_EXIST_TOOL_12345', - customKnowledgeQuery: 'Dify Agent E2E 测试暗号', + customKnowledgeQuery: 'Dify Agent E2E knowledge marker', + knowledgeRuntimeQuery: 'Use the connected knowledge source to find the Dify Agent E2E knowledge marker.', envPlainKey: 'E2E_AGENT_FLAG', envPlainValue: 'enabled', envModeKey: 'E2E_AGENT_MODE', diff --git a/e2e/features/agent-v2/support/agent-soul.ts b/e2e/features/agent-v2/support/agent-soul.ts index 1cdf59037e4..f3b3c7c6bb1 100644 --- a/e2e/features/agent-v2/support/agent-soul.ts +++ b/e2e/features/agent-v2/support/agent-soul.ts @@ -42,6 +42,11 @@ export const updatedAgentSoulConfig: AgentSoulConfig = { }, } +const stableAgentModelSettings = { + max_tokens: 4096, + temperature: 0, +} + const getAgentModelPluginId = (provider: string) => { const [organization, pluginName] = provider.split('/').filter(Boolean) @@ -71,10 +76,7 @@ export function createAgentSoulConfigWithModel( plugin_id: getAgentModelPluginId(model.provider), model_provider: model.provider, model: model.name, - model_settings: { - temperature: 0, - max_tokens: 512, - }, + model_settings: stableAgentModelSettings, }, } } @@ -103,3 +105,19 @@ export function createAgentSoulConfigWithKnowledgeDataset( }, } } + +export function createAgentSoulConfigWithDifyTool( + agentSoul: AgentSoulConfig, + tool: NonNullable['dify_tools']>[number], +): AgentSoulConfig { + return { + ...agentSoul, + tools: { + ...agentSoul.tools, + dify_tools: [ + ...(agentSoul.tools?.dify_tools ?? []), + tool, + ], + }, + } +} diff --git a/e2e/features/agent-v2/support/preflight/agent-backend.ts b/e2e/features/agent-v2/support/preflight/agent-backend.ts new file mode 100644 index 00000000000..4513a83a5b8 --- /dev/null +++ b/e2e/features/agent-v2/support/preflight/agent-backend.ts @@ -0,0 +1,120 @@ +import type { DifyWorld } from '../../../support/world' +import { skipBlockedPrecondition } from './common' + +const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true' + +const getDefaultAgentBackendURL = () => { + const port = process.env.E2E_AGENT_BACKEND_PORT?.trim() || '5050' + + return `http://127.0.0.1:${port}` +} + +const getShellctlURL = () => { + const explicitE2EURL = process.env.E2E_SHELLCTL_URL?.trim() + if (explicitE2EURL) + return explicitE2EURL.replace(/\/$/, '') + + const explicitAgentURL = process.env.DIFY_AGENT_SHELLCTL_ENTRYPOINT?.trim() + if (explicitAgentURL) + return explicitAgentURL.replace(/\/$/, '') + + if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) { + const port = process.env.E2E_SHELLCTL_PORT?.trim() || '5004' + return `http://127.0.0.1:${port}` + } + + return undefined +} + +const getAgentBackendURL = () => { + const explicitE2EURL = process.env.E2E_AGENT_BACKEND_URL?.trim() + if (explicitE2EURL) + return explicitE2EURL.replace(/\/$/, '') + + const explicitAPIURL = process.env.AGENT_BACKEND_BASE_URL?.trim() + if (explicitAPIURL) + return explicitAPIURL.replace(/\/$/, '') + + if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) + return getDefaultAgentBackendURL() + + return undefined +} + +const checkRuntimeOpenApi = async ({ + remediation, + title, + url, + world, +}: { + remediation: string + title: string + url: string + world: DifyWorld +}) => { + const healthURL = `${url}/openapi.json` + try { + const response = await fetch(healthURL) + if (response.ok) + return undefined + + return skipBlockedPrecondition( + world, + `${title} did not respond successfully at ${healthURL}: ${response.status} ${response.statusText}.`, + { + owner: 'e2e/runtime', + remediation, + }, + ) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + + return skipBlockedPrecondition( + world, + `${title} is unreachable at ${healthURL}: ${message}.`, + { + owner: 'e2e/runtime', + remediation, + }, + ) + } +} + +export async function skipMissingAgentBackendRuntime(world: DifyWorld) { + const agentBackendURL = getAgentBackendURL() + + if (!agentBackendURL) { + return skipBlockedPrecondition( + world, + 'Agent v2 runtime backend is not configured. This scenario needs the standalone dify-agent run server, not just an active model provider.', + { + owner: 'e2e/runtime', + remediation: 'Run with E2E_START_AGENT_BACKEND=1 to let E2E start dify-agent, or set E2E_AGENT_BACKEND_URL/AGENT_BACKEND_BASE_URL to an existing dify-agent server.', + }, + ) + } + + const agentBackendBlock = await checkRuntimeOpenApi({ + remediation: 'Start a healthy dify-agent server and make sure AGENT_BACKEND_BASE_URL points to it before running Agent v2 runtime scenarios.', + title: 'Agent v2 runtime backend', + url: agentBackendURL, + world, + }) + if (agentBackendBlock) + return agentBackendBlock + + const shellctlURL = getShellctlURL() + if (shellctlURL) { + const shellctlBlock = await checkRuntimeOpenApi({ + remediation: 'Start the shellctl local sandbox, or run with E2E_START_AGENT_BACKEND=1 so E2E starts it together with dify-agent.', + title: 'Agent v2 shellctl sandbox', + url: shellctlURL, + world, + }) + if (shellctlBlock) + return shellctlBlock + } + + return agentBackendURL +} diff --git a/e2e/features/agent-v2/support/preflight/agents.ts b/e2e/features/agent-v2/support/preflight/agents.ts index 8fcb9519aa0..1d4ad2d7970 100644 --- a/e2e/features/agent-v2/support/preflight/agents.ts +++ b/e2e/features/agent-v2/support/preflight/agents.ts @@ -2,6 +2,7 @@ import type { AgentAppComposerResponse, AgentDriveListResponse, AgentDriveSkillListResponse, + AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' @@ -33,8 +34,11 @@ import { hasUnauthorizedToolCredentialState, skipMissingPreseededTool, splitToolDisplayName, + splitToolResourceId, } from './tools' +type AgentSoulDifyToolConfig = NonNullable['dify_tools']>[number] + const hasKnowledgeDataset = ( soul: Record, dataset: PreseededResource, @@ -84,6 +88,14 @@ const hasKnowledgeSet = ( }) } +const getOAuth2ToolCredentialFixture = (toolItems: unknown[]) => + toolItems.find((item) => { + const record = asRecord(item) + + return record.credential_type === 'oauth2' + && Boolean(asString(asRecord(record.credential_ref).id)) + }) as AgentSoulDifyToolConfig | undefined + export async function skipMissingPreseededAgent( world: DifyWorld, resourceName: string, @@ -223,7 +235,7 @@ export async function skipMissingPreseededFullConfigAgentCoreConfiguration( if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) missing.push(agentBuilderPreseededResources.summarySkill) - const [providerName = '', toolName = ''] = jsonTool.id.split('/') + const { providerName, toolName } = splitToolResourceId(jsonTool.id) const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) if ( parsedTool.ok @@ -297,7 +309,7 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) missing.push(agentBuilderPreseededResources.summarySkill) - const [jsonProviderName = '', jsonToolName = ''] = jsonTool.id.split('/') + const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId(jsonTool.id) const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) if ( parsedJsonTool.ok @@ -311,7 +323,7 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( missing.push(agentBuilderPreseededResources.jsonReplaceTool) } - const [tavilyProviderName = '', tavilyToolName = ''] = tavilyTool.id.split('/') + const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId(tavilyTool.id) const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) const tavilyEntry = parsedTavilyTool.ok ? findToolEntry(toolItems, { @@ -343,6 +355,59 @@ export async function skipMissingPreseededToolStatesAgentConfiguration( } } +export async function skipMissingPreseededOAuthToolAgentConfiguration( + world: DifyWorld, + agentName: string, +): Promise<'skipped' | PreseededResource> { + const agent = await skipMissingPreseededAgent(world, agentName) + if (agent === 'skipped') + return agent + + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) + await expectApiResponseOK(response, `Check preseeded Agent OAuth2 tool configuration ${agentName}`) + const body = (await response.json()) as AgentAppComposerResponse + const toolItems = asArray(asRecord(body.agent_soul?.tools).dify_tools) + const oauthTool = getOAuth2ToolCredentialFixture(toolItems) + + if (!oauthTool) { + return skipBlockedPrecondition( + world, + `Preseeded Agent "${agentName}" is missing an OAuth2 tool with a credential reference.`, + { + owner: 'seed', + remediation: 'Seed an Agent v2 fixture that includes a built-in OAuth2 tool with credential_type oauth2 and credential_ref.id.', + }, + ) + } + + return agent + } + finally { + await ctx.dispose() + } +} + +export async function getPreseededOAuthToolConfig(agentId: string): Promise { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/agent/${agentId}/composer`) + await expectApiResponseOK(response, `Get preseeded Agent OAuth2 tool config ${agentId}`) + const body = (await response.json()) as AgentAppComposerResponse + const toolItems = asArray(asRecord(body.agent_soul?.tools).dify_tools) + const oauthTool = getOAuth2ToolCredentialFixture(toolItems) + + if (!oauthTool) + throw new Error(`Preseeded Agent ${agentId} does not include an OAuth2 tool credential fixture.`) + + return oauthTool + } + finally { + await ctx.dispose() + } +} + export async function skipMissingPreseededDualRetrievalAgentConfiguration( world: DifyWorld, agentName: string, diff --git a/e2e/features/agent-v2/support/preflight/datasets.ts b/e2e/features/agent-v2/support/preflight/datasets.ts index b344ded7940..44841af3eb1 100644 --- a/e2e/features/agent-v2/support/preflight/datasets.ts +++ b/e2e/features/agent-v2/support/preflight/datasets.ts @@ -9,6 +9,7 @@ import type { PreseededResource } from './common' import { createApiContext, expectApiResponseOK } from '../../../../support/api' import { agentBuilderExpectedTokens, + agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../agent-builder-resources' import { @@ -84,10 +85,10 @@ const getDatasetDocuments = async (datasetId: string, resourceName: string) => { } } -const datasetHasEnabledSegmentContainingToken = async ( +const datasetHasEnabledSegmentContainingTokens = async ( datasetId: string, resourceName: string, - expectedToken: string, + expectedTokens: string[], ) => { const documents = await getDatasetDocuments(datasetId, resourceName) const ctx = await createApiContext() @@ -95,7 +96,7 @@ const datasetHasEnabledSegmentContainingToken = async ( for (const document of documents) { const query = buildQuery({ enabled: 'true', - keyword: expectedToken, + keyword: agentBuilderExpectedTokens.knowledgeReply, limit: '20', page: '1', }) @@ -110,9 +111,9 @@ const datasetHasEnabledSegmentContainingToken = async ( const matchingSegment = body.data.find( segment => segment.enabled - && ( + && expectedTokens.every(expectedToken => segment.content.includes(expectedToken) - || segment.keywords?.some(keyword => keyword.includes(expectedToken)) + || segment.keywords?.some(keyword => keyword.includes(expectedToken)), ), ) @@ -184,18 +185,23 @@ export async function skipMissingReadyPreseededDataset( } if (resourceName === agentBuilderPreseededResources.agentKnowledgeBase) { - const hasExpectedToken = await datasetHasEnabledSegmentContainingToken( + const requiredTokens = [ + agentBuilderFixedInputs.customKnowledgeQuery, + agentBuilderFixedInputs.knowledgeRuntimeQuery, + agentBuilderExpectedTokens.knowledgeReply, + ] + const hasExpectedToken = await datasetHasEnabledSegmentContainingTokens( resource.id, resourceName, - agentBuilderExpectedTokens.knowledgeReply, + requiredTokens, ) if (!hasExpectedToken) { return skipBlockedPrecondition( world, - `Preseeded dataset "${resourceName}" has no enabled segment containing "${agentBuilderExpectedTokens.knowledgeReply}".`, + `Preseeded dataset "${resourceName}" has no enabled segment containing "${requiredTokens.join('" and "')}".`, { - remediation: `Seed the dataset from the Agent Builder knowledge fixture and wait until an enabled segment contains "${agentBuilderExpectedTokens.knowledgeReply}".`, + remediation: `Seed the dataset from the Agent Builder knowledge fixture and wait until an enabled segment contains "${requiredTokens.join('" and "')}".`, }, ) } diff --git a/e2e/features/agent-v2/support/preflight/models.ts b/e2e/features/agent-v2/support/preflight/models.ts index 330c61c9d48..5c05c4187bf 100644 --- a/e2e/features/agent-v2/support/preflight/models.ts +++ b/e2e/features/agent-v2/support/preflight/models.ts @@ -7,13 +7,19 @@ import { skipBlockedPrecondition } from './common' const stableChatModelProviderEnv = 'E2E_STABLE_MODEL_PROVIDER' const stableChatModelNameEnv = 'E2E_STABLE_MODEL_NAME' const stableChatModelTypeEnv = 'E2E_STABLE_MODEL_TYPE' +const agentDecisionChatModelProviderEnv = 'E2E_AGENT_DECISION_MODEL_PROVIDER' +const agentDecisionChatModelNameEnv = 'E2E_AGENT_DECISION_MODEL_NAME' +const agentDecisionChatModelTypeEnv = 'E2E_AGENT_DECISION_MODEL_TYPE' const brokenChatModelProviderEnv = 'E2E_BROKEN_MODEL_PROVIDER' const brokenChatModelNameEnv = 'E2E_BROKEN_MODEL_NAME' const brokenChatModelTypeEnv = 'E2E_BROKEN_MODEL_TYPE' const activeModelStatus = 'active' const defaultStableChatModelProvider = 'openai' -const defaultStableChatModelName = 'gpt-5.4-mini' +const defaultStableChatModelName = 'gpt-5-nano' const defaultStableChatModelType = 'llm' +const defaultAgentDecisionChatModelProvider = 'openai' +const defaultAgentDecisionChatModelName = 'gpt-5.5' +const defaultAgentDecisionChatModelType = 'llm' const defaultBrokenChatModelName = agentBuilderPreseededResources.brokenModel const getProviderAlias = (provider: string) => provider.split('/').filter(Boolean).at(-1) ?? provider @@ -48,6 +54,23 @@ export function readAgentBuilderStableChatModelConfig(): ModelPreflightConfig { } } +export function readAgentBuilderAgentDecisionChatModelConfig(): ModelPreflightConfig { + const provider = process.env[agentDecisionChatModelProviderEnv]?.trim() + || defaultAgentDecisionChatModelProvider + const name = process.env[agentDecisionChatModelNameEnv]?.trim() + || defaultAgentDecisionChatModelName + const type = process.env[agentDecisionChatModelTypeEnv]?.trim() + || defaultAgentDecisionChatModelType + + return { + ok: true, + provider, + resourceName: agentBuilderPreseededResources.agentDecisionChatModel, + type, + value: name, + } +} + export function readAgentBuilderBrokenChatModelConfig(): ModelPreflightConfig { const provider = process.env[brokenChatModelProviderEnv]?.trim() const name = process.env[brokenChatModelNameEnv]?.trim() || defaultBrokenChatModelName @@ -129,6 +152,14 @@ export async function skipMissingAgentBuilderStableChatModel( }) } +export async function skipMissingAgentBuilderAgentDecisionChatModel( + world: DifyWorld, +): Promise<'skipped' | NonNullable> { + return skipMissingAgentBuilderModel(world, readAgentBuilderAgentDecisionChatModelConfig(), { + requireActive: true, + }) +} + export async function skipMissingAgentBuilderBrokenChatModel( world: DifyWorld, ): Promise<'skipped' | NonNullable> { diff --git a/e2e/features/agent-v2/support/preflight/tools.ts b/e2e/features/agent-v2/support/preflight/tools.ts index 9f20ee0e8e5..634a6318afa 100644 --- a/e2e/features/agent-v2/support/preflight/tools.ts +++ b/e2e/features/agent-v2/support/preflight/tools.ts @@ -36,6 +36,17 @@ export const splitToolDisplayName = (resourceName: string) => { } } +export const splitToolResourceId = (resourceId: string) => { + const parts = resourceId.split('/').map(item => item.trim()).filter(Boolean) + const toolName = parts.at(-1) + const providerName = parts.slice(0, -1).join('/') + + return { + providerName, + toolName: toolName ?? '', + } +} + export const findToolEntry = ( items: unknown[], { diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts new file mode 100644 index 00000000000..e3a3eb40bc1 --- /dev/null +++ b/e2e/features/agent-v2/support/seed.ts @@ -0,0 +1,1030 @@ +import type { AgentKnowledgeDatasetConfig, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { + ConsoleSegmentListResponse, + DatasetListItemResponse, + DocumentStatusListResponse, + DocumentWithSegmentsListResponse, + KnowledgeConfig, +} from '@dify/contracts/api/console/datasets/types.gen' +import type { + ModelProviderListResponse, + ProviderWithModelsDataResponse, +} from '@dify/contracts/api/console/workspaces/types.gen' +import type { SeedContext, SeedResource, SeedTask } from '../../../support/seed' +import type { UploadedConsoleFile } from './agent-drive' +import { readFile } from 'node:fs/promises' +import { + createApiContext, + createTestApp, + expectApiResponseOK, + publishWorkflowApp, + syncAgentV2WorkflowDraft, +} from '../../../support/api' +import { bootstrapMarketplacePlugins } from '../../../support/marketplace-plugins' +import { sleep } from '../../../support/process' +import { + blocked, + created, + skipped, + updated, + verified, +} from '../../../support/seed' +import { + createAgentApiKey, + setAgentApiAccess, + setAgentSiteAccessAndGetURL, +} from './access-point' +import { + createTestAgent, + publishAgent, + saveAgentComposerDraft, +} from './agent' +import { + agentBuilderExpectedTokens, + agentBuilderFixedInputs, + agentBuilderPreseededResources, +} from './agent-builder-resources' +import { + getAgentDriveSkills, + uploadAgentConfigFileToDraft, + uploadAgentConfigSkillToDraft, + uploadAgentDriveSkill, +} from './agent-drive' +import { + createAgentSoulConfigWithKnowledgeDataset, + createAgentSoulConfigWithModel, + normalAgentSoulConfig, +} from './agent-soul' +import { + buildQuery, + findConsoleResourceByName, + isRecord, + matchesNameOrLabel, +} from './preflight/common' +import { splitToolDisplayName } from './preflight/tools' +import { + agentBuilderTestMaterials, + getAgentBuilderTestMaterialPath, +} from './test-materials' + +type StableModel = { + name: string + provider: string + type: string +} + +type ToolResource = SeedResource & { + providerName: string + toolName: string +} + +const modelCredentialEnv = 'E2E_MODEL_PROVIDER_CREDENTIALS_JSON' +const marketplacePluginIdsEnv = 'E2E_MARKETPLACE_PLUGIN_IDS' +const marketplacePluginUniqueIdentifiersEnv = 'E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS' +const oauthToolCredentialIdEnv = 'E2E_OAUTH_TOOL_CREDENTIAL_ID' +const oauthToolProviderEnv = 'E2E_OAUTH_TOOL_PROVIDER' +const oauthToolNameEnv = 'E2E_OAUTH_TOOL_NAME' +const activeModelStatus = 'active' +const stableModelCredentialName = 'E2E Stable Model' +const agentV2MarketplacePluginIds = [ + 'langgenius/openai', + 'langgenius/json_process', + 'langgenius/tavily', +] + +const getProviderAlias = (provider: string) => provider.split('/').filter(Boolean).at(-1) ?? provider + +const matchesProvider = (actual: string, expected: string) => + actual === expected || getProviderAlias(actual) === getProviderAlias(expected) + +const matchesProviderLabel = (provider: { label?: { en_US?: string | null, zh_Hans?: string | null } | null, provider: string }, expected: string) => + matchesProvider(provider.provider, expected) + || provider.label?.en_US === expected + || provider.label?.zh_Hans === expected + +const stableModelConfig = (): StableModel => ({ + name: process.env.E2E_STABLE_MODEL_NAME?.trim() || 'gpt-5-nano', + provider: process.env.E2E_STABLE_MODEL_PROVIDER?.trim() || 'openai', + type: process.env.E2E_STABLE_MODEL_TYPE?.trim() || 'llm', +}) + +const agentDecisionModelConfig = (): StableModel => ({ + name: process.env.E2E_AGENT_DECISION_MODEL_NAME?.trim() || 'gpt-5.5', + provider: process.env.E2E_AGENT_DECISION_MODEL_PROVIDER?.trim() || 'openai', + type: process.env.E2E_AGENT_DECISION_MODEL_TYPE?.trim() || 'llm', +}) + +const parseJsonEnv = (envName: string) => { + const raw = process.env[envName]?.trim() + if (!raw) + return { ok: false as const, reason: `${envName} is required.` } + + try { + const value = JSON.parse(raw) as unknown + if (!isRecord(value)) + return { ok: false as const, reason: `${envName} must be a JSON object.` } + + return { ok: true as const, value } + } + catch (error) { + return { + ok: false as const, + reason: `${envName} must be valid JSON: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +const findChatModel = async (config: StableModel, title: string) => { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/workspaces/current/models/model-types/${config.type}`) + await expectApiResponseOK(response, `Check ${title}`) + const body = (await response.json()) as ProviderWithModelsDataResponse + const provider = body.data.find(item => matchesProvider(item.provider, config.provider)) + const model = provider?.models.find( + item => + item.model === config.name + || item.label?.en_US === config.name + || item.label?.zh_Hans === config.name, + ) + + if (!provider || !model) + return undefined + + return { + name: model.model, + provider: provider.provider, + status: model.status, + type: config.type, + } + } + finally { + await ctx.dispose() + } +} + +const resolveProvider = async (config: StableModel) => { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/workspaces/current/model-providers?${buildQuery({ model_type: config.type })}`) + await expectApiResponseOK(response, `Resolve model provider ${config.provider}`) + const body = (await response.json()) as ModelProviderListResponse + const provider = body.data.find(item => matchesProviderLabel(item, config.provider)) + + return { + availableProviders: body.data.map(provider => provider.provider), + credential: provider?.custom_configuration.available_credentials?.find( + credential => credential.credential_name === stableModelCredentialName, + ), + provider: provider?.provider, + } + } + finally { + await ctx.dispose() + } +} + +const selectCustomProviderCredential = async (provider: string, credentialId?: string) => { + const ctx = await createApiContext() + try { + if (credentialId) { + const switchResponse = await ctx.post( + `/console/api/workspaces/current/model-providers/${provider}/credentials/switch`, + { + data: { credential_id: credentialId }, + }, + ) + await expectApiResponseOK(switchResponse, `Switch model provider credential for ${provider}`) + } + + const preferredResponse = await ctx.post( + `/console/api/workspaces/current/model-providers/${provider}/preferred-provider-type`, + { + data: { preferred_provider_type: 'custom' }, + }, + ) + await expectApiResponseOK(preferredResponse, `Select custom provider credential for ${provider}`) + } + finally { + await ctx.dispose() + } +} + +const upsertStableProviderCredential = async ( + provider: string, + credentials: Record, + credentialId?: string, +) => { + const ctx = await createApiContext() + try { + if (credentialId) { + const updateResponse = await ctx.put( + `/console/api/workspaces/current/model-providers/${provider}/credentials`, + { + data: { + credential_id: credentialId, + credentials, + name: stableModelCredentialName, + }, + }, + ) + await expectApiResponseOK(updateResponse, `Update model provider credential for ${provider}`) + return + } + + const createResponse = await ctx.post( + `/console/api/workspaces/current/model-providers/${provider}/credentials`, + { + data: { + credentials, + name: stableModelCredentialName, + }, + }, + ) + await expectApiResponseOK(createResponse, `Create model provider credential for ${provider}`) + } + finally { + await ctx.dispose() + } +} + +const seedChatModel = async (context: SeedContext, { + config, + title, +}: { + config: StableModel + title: string +}) => { + const existing = await findChatModel(config, title) + const resource = { + id: `${existing?.provider ?? config.provider}/${existing?.name ?? config.name}`, + kind: 'model', + name: title, + } + + if (existing?.status === activeModelStatus) + return verified(title, resource) + + if (context.dryRun) + return skipped(title, `Would configure ${config.provider}/${config.name} using ${modelCredentialEnv}.`) + + const credentials = parseJsonEnv(modelCredentialEnv) + if (!credentials.ok) + return blocked(title, `${config.provider}/${config.name} is not active; ${credentials.reason}`) + + const { availableProviders, credential, provider } = await resolveProvider(config) + if (!provider) { + const available = availableProviders.length > 0 + ? availableProviders.join(', ') + : 'none' + return blocked( + title, + `Provider ${config.provider} was not found in available model providers for ${config.type}. Available providers: ${available}.`, + ) + } + + try { + await upsertStableProviderCredential(provider, credentials.value, credential?.credential_id) + await selectCustomProviderCredential(provider, credential?.credential_id) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!message.includes(`Credential with name '${stableModelCredentialName}' already exists.`)) + return blocked(title, message) + + const refreshed = await resolveProvider(config) + if (!refreshed.provider || !refreshed.credential) { + return blocked( + title, + `Credential ${stableModelCredentialName} already exists for ${provider}, but the seed could not resolve its credential id.`, + ) + } + + try { + await upsertStableProviderCredential( + refreshed.provider, + credentials.value, + refreshed.credential.credential_id, + ) + await selectCustomProviderCredential(refreshed.provider, refreshed.credential.credential_id) + } + catch (retryError) { + return blocked(title, retryError instanceof Error ? retryError.message : String(retryError)) + } + } + + const seeded = await findChatModel(config, title) + if (seeded?.status !== activeModelStatus) { + return blocked( + title, + `${config.provider}/${config.name} is ${seeded?.status ?? 'missing'} after credential setup.`, + ) + } + + return updated(title, { + id: `${seeded.provider}/${seeded.name}`, + kind: 'model', + name: title, + }) +} + +const seedStableModel = async (context: SeedContext) => seedChatModel(context, { + config: stableModelConfig(), + title: agentBuilderPreseededResources.stableChatModel, +}) + +const seedAgentDecisionModel = async (context: SeedContext) => seedChatModel(context, { + config: agentDecisionModelConfig(), + title: agentBuilderPreseededResources.agentDecisionChatModel, +}) + +type BuiltinToolProvider = { + label?: { en_US?: string, zh_Hans?: string } + name: string + tools: Array<{ + label?: { en_US?: string, zh_Hans?: string } + name: string + }> +} + +const findBuiltinTool = async (displayName: string) => { + const parsed = splitToolDisplayName(displayName) + if (!parsed.ok) + return { ok: false as const, reason: parsed.reason } + + const ctx = await createApiContext() + try { + const response = await ctx.get('/console/api/workspaces/current/tools/builtin') + await expectApiResponseOK(response, `Check built-in tool ${displayName}`) + const providers = (await response.json()) as BuiltinToolProvider[] + const provider = providers.find(item => + matchesNameOrLabel(parsed.providerName, item.name, item.label)) + const tool = provider?.tools.find(item => + matchesNameOrLabel(parsed.toolName, item.name, item.label)) + + if (!provider || !tool) + return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } + + return { + ok: true as const, + resource: { + id: `${provider.name}/${tool.name}`, + kind: 'tool', + name: displayName, + providerName: provider.name, + toolName: tool.name, + } satisfies ToolResource, + } + } + finally { + await ctx.dispose() + } +} + +const seedTool = (displayName: string): SeedTask => ({ + id: `tool:${displayName}`, + title: displayName, + async run() { + const result = await findBuiltinTool(displayName) + if (!result.ok) + return blocked(displayName, result.reason) + + return verified(displayName, result.resource) + }, +}) + +const uploadConsoleFile = async (fileName: string, filePath: string): Promise => { + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/files/upload', { + multipart: { + file: { + buffer: await readFile(filePath), + mimeType: 'text/plain', + name: fileName, + }, + }, + }) + await expectApiResponseOK(response, `Upload seed file ${fileName}`) + return (await response.json()) as UploadedConsoleFile + } + finally { + await ctx.dispose() + } +} + +const findDataset = (name: string) => { + const query = buildQuery({ keyword: name, limit: '20', page: '1' }) + return findConsoleResourceByName({ + action: `Find seed dataset ${name}`, + path: `/console/api/datasets?${query}`, + resourceName: name, + }) +} + +const getDatasetDocuments = async (datasetId: string) => { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/datasets/${datasetId}/documents?${buildQuery({ limit: '100', page: '1' })}`) + await expectApiResponseOK(response, `List dataset documents ${datasetId}`) + const body = (await response.json()) as DocumentWithSegmentsListResponse + return body.data + } + finally { + await ctx.dispose() + } +} + +const requiredKnowledgeSegmentTokens = [ + agentBuilderFixedInputs.customKnowledgeQuery, + agentBuilderFixedInputs.knowledgeRuntimeQuery, + agentBuilderExpectedTokens.knowledgeReply, +] + +const datasetHasKnowledgeSegment = async (datasetId: string) => { + const documents = await getDatasetDocuments(datasetId) + const ctx = await createApiContext() + try { + for (const document of documents) { + const response = await ctx.get( + `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${buildQuery({ + enabled: 'true', + keyword: agentBuilderExpectedTokens.knowledgeReply, + limit: '20', + page: '1', + })}`, + ) + await expectApiResponseOK(response, `Check dataset knowledge segment ${agentBuilderExpectedTokens.knowledgeReply}`) + const body = (await response.json()) as ConsoleSegmentListResponse + if (body.data.some(segment => + segment.enabled + && requiredKnowledgeSegmentTokens.every(token => segment.content.includes(token)), + )) { + return true + } + } + + return false + } + finally { + await ctx.dispose() + } +} + +const waitForDatasetCompleted = async (datasetId: string) => { + const deadline = Date.now() + 180_000 + let status = 'missing' + + while (Date.now() < deadline) { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) + await expectApiResponseOK(response, `Check dataset indexing ${datasetId}`) + const body = (await response.json()) as DocumentStatusListResponse + status = body.data.length < 1 + ? 'missing' + : body.data.every(item => item.indexing_status === 'completed') + ? 'completed' + : body.data.map(item => item.indexing_status ?? 'missing').join(',') + + if (status === 'completed') + return { ok: true as const } + } + finally { + await ctx.dispose() + } + + await sleep(1_000) + } + + return { ok: false as const, status } +} + +const addKnowledgeDocument = async (datasetId: string) => { + const uploadedFile = await uploadConsoleFile( + agentBuilderTestMaterials.knowledgeSource, + getAgentBuilderTestMaterialPath('knowledgeSource'), + ) + const body = { + data_source: { + info_list: { + data_source_type: 'upload_file', + file_info_list: { + file_ids: [uploadedFile.id], + }, + }, + }, + doc_form: 'text_model', + doc_language: 'English', + indexing_technique: 'economy', + process_rule: { + mode: 'automatic', + }, + retrieval_model: { + reranking_enable: false, + score_threshold_enabled: false, + search_method: 'keyword_search', + top_k: 4, + }, + } satisfies KnowledgeConfig + + const ctx = await createApiContext() + try { + const response = await ctx.post(`/console/api/datasets/${datasetId}/documents`, { data: body }) + await expectApiResponseOK(response, `Seed knowledge document ${datasetId}`) + } + finally { + await ctx.dispose() + } +} + +const createDataset = async (name: string) => { + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/datasets', { + data: { + indexing_technique: 'economy', + name, + permission: 'only_me', + provider: 'vendor', + }, + }) + await expectApiResponseOK(response, `Create dataset ${name}`) + return (await response.json()) as DatasetListItemResponse + } + finally { + await ctx.dispose() + } +} + +const seedReadyKnowledge = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.agentKnowledgeBase + let dataset = await findDataset(title) + + if (context.dryRun) { + return dataset + ? verified(title, { id: dataset.id, kind: 'dataset', name: title }) + : skipped(title, `Would create dataset "${title}".`) + } + + const wasCreated = !dataset + dataset ??= await createDataset(title) + + const hasKnowledgeSegment = await datasetHasKnowledgeSegment(dataset.id) + if (!hasKnowledgeSegment) + await addKnowledgeDocument(dataset.id) + + const indexing = await waitForDatasetCompleted(dataset.id) + if (!indexing.ok) { + return blocked( + title, + `Dataset "${title}" indexing did not complete in seed timeout; last status: ${indexing.status}.`, + ) + } + + return datasetHasKnowledgeSegment(dataset.id) + .then((ready) => { + if (!ready) { + return blocked( + title, + `Dataset "${title}" does not expose an indexed segment containing ${requiredKnowledgeSegmentTokens.join(' and ')} after indexing.`, + ) + } + + const resource = { id: dataset.id, kind: 'dataset', name: title } + return wasCreated ? created(title, resource) : verified(title, resource) + }) +} + +const ensureAgent = async (name: string) => { + const query = buildQuery({ limit: '20', name, page: '1' }) + const existing = await findConsoleResourceByName({ + action: `Find seed Agent ${name}`, + path: `/console/api/agent?${query}`, + resourceName: name, + }) + + if (existing) + return { agent: existing, created: false } + + const agent = await createTestAgent({ + description: 'Created by Dify E2E seed.', + name, + role: 'E2E seeded assistant', + }) + + return { agent, created: true } +} + +const getStableModelResource = (context: SeedContext): StableModel | undefined => { + const resource = context.resources.get('stable-model') + if (!resource?.id) + return undefined + + const separatorIndex = resource.id.lastIndexOf('/') + if (separatorIndex === -1) + return undefined + + return { + name: resource.id.slice(separatorIndex + 1), + provider: resource.id.slice(0, separatorIndex), + type: stableModelConfig().type, + } +} + +const getToolResource = (context: SeedContext, displayName: string) => + context.resources.get(`tool:${displayName}`) as ToolResource | undefined + +const toolConfig = (tool: ToolResource) => ({ + credential_type: 'unauthorized' as const, + enabled: true, + provider_id: tool.providerName, + provider_type: 'builtin', + runtime_parameters: {}, + tool_name: tool.toolName, +}) + +const saveSeededAgentComposer = async ({ + agentId, + config, + shouldPublish = false, +}: { + agentId: string + config: AgentSoulConfig + shouldPublish?: boolean +}) => { + await saveAgentComposerDraft(agentId, config) + if (shouldPublish) + await publishAgent(agentId, 'E2E seed') +} + +const ensureDriveSkill = async (agentId: string) => { + const skills = await getAgentDriveSkills(agentId) + if (skills.some(skill => skill.name === agentBuilderPreseededResources.summarySkill)) + return + + await uploadAgentDriveSkill({ + agentId, + fileName: agentBuilderTestMaterials.summarySkill, + filePath: getAgentBuilderTestMaterialPath('summarySkill'), + }) +} + +const seedFullConfigAgent = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.fullConfigAgent + const model = getStableModelResource(context) + const jsonTool = getToolResource(context, agentBuilderPreseededResources.jsonReplaceTool) + const dataset = context.resources.get('ready-knowledge') + if (!model) + return blocked(title, `${agentBuilderPreseededResources.stableChatModel} is not ready.`) + if (!jsonTool) + return blocked(title, `${agentBuilderPreseededResources.jsonReplaceTool} is not ready.`) + if (!dataset?.id) + return blocked(title, `${agentBuilderPreseededResources.agentKnowledgeBase} is not ready.`) + + if (context.dryRun) + return skipped(title, `Would create or update Agent "${title}".`) + + const { agent, created: wasCreated } = await ensureAgent(title) + const agentId = agent.id + const smallFile = await uploadAgentConfigFileToDraft({ + agentId, + fileName: agentBuilderTestMaterials.smallFile, + filePath: getAgentBuilderTestMaterialPath('smallFile'), + }) + const specialFile = await uploadAgentConfigFileToDraft({ + agentId, + fileName: agentBuilderTestMaterials.specialFilename, + filePath: getAgentBuilderTestMaterialPath('specialFilename'), + }) + const summarySkill = await uploadAgentConfigSkillToDraft({ + agentId, + fileName: agentBuilderTestMaterials.summarySkill, + filePath: getAgentBuilderTestMaterialPath('summarySkill'), + }) + await ensureDriveSkill(agentId) + + await saveSeededAgentComposer({ + agentId, + config: createAgentSoulConfigWithKnowledgeDataset( + createAgentSoulConfigWithModel({ + ...normalAgentSoulConfig, + config_files: [smallFile, specialFile], + config_skills: [summarySkill], + tools: { + dify_tools: [toolConfig(jsonTool)], + }, + }, model), + { + id: dataset.id, + name: dataset.name, + } satisfies AgentKnowledgeDatasetConfig, + ), + }) + + const resource = { id: agentId, kind: 'agent', name: title } + return wasCreated ? created(title, resource) : updated(title, resource) +} + +const seedToolStatesAgent = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.toolStatesAgent + const jsonTool = getToolResource(context, agentBuilderPreseededResources.jsonReplaceTool) + const tavilyTool = getToolResource(context, agentBuilderPreseededResources.tavilySearchTool) + if (!jsonTool) + return blocked(title, `${agentBuilderPreseededResources.jsonReplaceTool} is not ready.`) + if (!tavilyTool) + return blocked(title, `${agentBuilderPreseededResources.tavilySearchTool} is not ready.`) + if (context.dryRun) + return skipped(title, `Would create or update Agent "${title}".`) + + const { agent, created: wasCreated } = await ensureAgent(title) + const summarySkill = await uploadAgentConfigSkillToDraft({ + agentId: agent.id, + fileName: agentBuilderTestMaterials.summarySkill, + filePath: getAgentBuilderTestMaterialPath('summarySkill'), + }) + await ensureDriveSkill(agent.id) + await saveSeededAgentComposer({ + agentId: agent.id, + config: { + ...normalAgentSoulConfig, + config_skills: [summarySkill], + tools: { + dify_tools: [toolConfig(jsonTool), toolConfig(tavilyTool)], + }, + }, + }) + + const resource = { id: agent.id, kind: 'agent', name: title } + return wasCreated ? created(title, resource) : updated(title, resource) +} + +const seedDualRetrievalAgent = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.dualRetrievalAgent + const dataset = context.resources.get('ready-knowledge') + if (!dataset?.id) + return blocked(title, `${agentBuilderPreseededResources.agentKnowledgeBase} is not ready.`) + if (context.dryRun) + return skipped(title, `Would create or update Agent "${title}".`) + + const { agent, created: wasCreated } = await ensureAgent(title) + const datasetConfig = { id: dataset.id, name: dataset.name } satisfies AgentKnowledgeDatasetConfig + await saveSeededAgentComposer({ + agentId: agent.id, + config: { + ...normalAgentSoulConfig, + knowledge: { + sets: [ + { + datasets: [datasetConfig], + id: 'e2e-dual-retrieval-agent-decide', + name: 'Retrieval 1', + query: { mode: 'generated_query' }, + retrieval: { mode: 'multiple', top_k: 4 }, + }, + { + datasets: [datasetConfig], + id: 'e2e-dual-retrieval-custom-query', + name: 'Retrieval 2', + query: { + mode: 'user_query', + value: agentBuilderFixedInputs.customKnowledgeQuery, + }, + retrieval: { mode: 'multiple', top_k: 4 }, + }, + ], + }, + }, + }) + + const resource = { id: agent.id, kind: 'agent', name: title } + return wasCreated ? created(title, resource) : updated(title, resource) +} + +const seedPublishedWebAppAgent = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.publishedWebAppAgent + const model = getStableModelResource(context) + if (!model) + return blocked(title, `${agentBuilderPreseededResources.stableChatModel} is not ready.`) + if (context.dryRun) + return skipped(title, `Would create or update Agent "${title}".`) + + const { agent, created: wasCreated } = await ensureAgent(title) + await saveSeededAgentComposer({ + agentId: agent.id, + config: createAgentSoulConfigWithModel(normalAgentSoulConfig, model), + shouldPublish: true, + }) + await setAgentSiteAccessAndGetURL(agent.id, true) + + const resource = { id: agent.id, kind: 'agent', name: title } + return wasCreated ? created(title, resource) : updated(title, resource) +} + +const seedBackendApiAgent = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.backendApiEnabledAgent + const model = getStableModelResource(context) + if (!model) + return blocked(title, `${agentBuilderPreseededResources.stableChatModel} is not ready.`) + if (context.dryRun) + return skipped(title, `Would create or update Agent "${title}".`) + + const { agent, created: wasCreated } = await ensureAgent(title) + await saveSeededAgentComposer({ + agentId: agent.id, + config: createAgentSoulConfigWithModel(normalAgentSoulConfig, model), + shouldPublish: true, + }) + const access = await setAgentApiAccess(agent.id, true) + if (access.api_key_count < 1) + await createAgentApiKey(agent.id) + + const resource = { id: agent.id, kind: 'agent', name: title } + return wasCreated ? created(title, resource) : updated(title, resource) +} + +const findWorkflow = (name: string) => { + const query = buildQuery({ limit: '20', mode: 'workflow', name, page: '1' }) + return findConsoleResourceByName({ + action: `Find seed workflow ${name}`, + path: `/console/api/apps?${query}`, + resourceName: name, + }) +} + +const seedWorkflowReference = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.workflowReferenceAgent + const workflowName = agentBuilderPreseededResources.referenceWorkflow + const model = getStableModelResource(context) + if (!model) + return blocked(title, `${agentBuilderPreseededResources.stableChatModel} is not ready.`) + if (context.dryRun) + return skipped(title, `Would create or update Agent "${title}" and workflow "${workflowName}".`) + + const { agent, created: wasAgentCreated } = await ensureAgent(title) + await saveSeededAgentComposer({ + agentId: agent.id, + config: createAgentSoulConfigWithModel(normalAgentSoulConfig, model), + shouldPublish: true, + }) + + let workflow = await findWorkflow(workflowName) + let wasWorkflowCreated = false + if (!workflow) { + workflow = await createTestApp(workflowName, 'workflow') + wasWorkflowCreated = true + } + await syncAgentV2WorkflowDraft(workflow.id, agent.id) + await publishWorkflowApp(workflow.id) + + const resource = { id: workflow.id, kind: 'workflow', name: workflowName } + return wasAgentCreated || wasWorkflowCreated + ? created(`${title} / ${workflowName}`, resource) + : updated(`${title} / ${workflowName}`, resource) +} + +const seedOAuthToolAgent = async (context: SeedContext) => { + const title = agentBuilderPreseededResources.oauthToolAgent + const credentialId = process.env[oauthToolCredentialIdEnv]?.trim() + const providerName = process.env[oauthToolProviderEnv]?.trim() + const toolName = process.env[oauthToolNameEnv]?.trim() + if (!credentialId || !providerName || !toolName) { + return blocked( + title, + `${oauthToolCredentialIdEnv}, ${oauthToolProviderEnv}, and ${oauthToolNameEnv} are required for OAuth2 tool seed.`, + ) + } + if (context.dryRun) + return skipped(title, `Would create or update Agent "${title}" with OAuth2 tool ${providerName}/${toolName}.`) + + const { agent, created: wasCreated } = await ensureAgent(title) + await saveSeededAgentComposer({ + agentId: agent.id, + config: { + ...normalAgentSoulConfig, + tools: { + dify_tools: [ + { + credential_ref: { + id: credentialId, + provider: providerName, + type: 'provider', + }, + credential_type: 'oauth2', + enabled: true, + provider_id: providerName, + provider_type: 'builtin', + runtime_parameters: {}, + tool_name: toolName, + }, + ], + }, + }, + }) + + const resource = { id: agent.id, kind: 'agent', name: title } + return wasCreated ? created(title, resource) : updated(title, resource) +} + +const agentV2BaseSeedTasks = (): SeedTask[] => [ + { + id: 'marketplace-plugins', + title: 'Agent V2 marketplace plugins', + run: context => bootstrapMarketplacePlugins(context, { + defaultPluginIds: agentV2MarketplacePluginIds, + pluginIdsEnv: marketplacePluginIdsEnv, + pluginUniqueIdentifiersEnv: marketplacePluginUniqueIdentifiersEnv, + title: 'Agent V2 marketplace plugins', + }), + }, + { + id: 'stable-model', + title: agentBuilderPreseededResources.stableChatModel, + run: seedStableModel, + }, + { + id: 'agent-decision-model', + title: agentBuilderPreseededResources.agentDecisionChatModel, + run: seedAgentDecisionModel, + }, + seedTool(agentBuilderPreseededResources.jsonReplaceTool), + seedTool(agentBuilderPreseededResources.tavilySearchTool), + { + id: 'ready-knowledge', + title: agentBuilderPreseededResources.agentKnowledgeBase, + run: seedReadyKnowledge, + }, +] + +const agentV2FullSeedTasks = (): SeedTask[] => [ + ...agentV2BaseSeedTasks(), + { + id: 'indexing-knowledge', + title: agentBuilderPreseededResources.indexingKnowledgeBase, + run: async () => blocked( + agentBuilderPreseededResources.indexingKnowledgeBase, + 'A deterministic long-lived "currently indexing" dataset seed is not implemented yet.', + ), + }, + { + id: 'broken-model', + title: agentBuilderPreseededResources.brokenModelProvider, + run: async () => blocked( + agentBuilderPreseededResources.brokenModelProvider, + 'Broken model fixture is validation-only for now; provide E2E_BROKEN_MODEL_PROVIDER and keep the model entry externally.', + ), + }, + { + id: 'full-config-agent', + title: agentBuilderPreseededResources.fullConfigAgent, + run: seedFullConfigAgent, + }, + { + id: 'tool-states-agent', + title: agentBuilderPreseededResources.toolStatesAgent, + run: seedToolStatesAgent, + }, + { + id: 'oauth-tool-agent', + title: agentBuilderPreseededResources.oauthToolAgent, + run: seedOAuthToolAgent, + }, + { + id: 'file-tree-agent', + title: agentBuilderPreseededResources.fileTreeAgent, + run: async () => blocked( + agentBuilderPreseededResources.fileTreeAgent, + 'Agent drive arbitrary file upload does not have a stable public seed helper yet.', + ), + }, + { + id: 'dual-retrieval-agent', + title: agentBuilderPreseededResources.dualRetrievalAgent, + run: seedDualRetrievalAgent, + }, + { + id: 'published-web-app-agent', + title: agentBuilderPreseededResources.publishedWebAppAgent, + run: seedPublishedWebAppAgent, + }, + { + id: 'backend-api-agent', + title: agentBuilderPreseededResources.backendApiEnabledAgent, + run: seedBackendApiAgent, + }, + { + id: 'workflow-reference', + title: `${agentBuilderPreseededResources.workflowReferenceAgent} / ${agentBuilderPreseededResources.referenceWorkflow}`, + run: seedWorkflowReference, + }, +] + +export const createAgentV2SeedTasks = (profile: string = 'full'): SeedTask[] => { + if (profile === 'full') + return agentV2FullSeedTasks() + + if (profile === 'external-runtime') + return agentV2BaseSeedTasks() + + throw new Error(`Unknown Agent V2 seed profile "${profile}".`) +} diff --git a/e2e/features/agent-v2/support/tools.ts b/e2e/features/agent-v2/support/tools.ts index d7f6eb60fe5..ccae6f92e69 100644 --- a/e2e/features/agent-v2/support/tools.ts +++ b/e2e/features/agent-v2/support/tools.ts @@ -1,5 +1,5 @@ import type { DifyWorld } from '../../support/world' -import { splitToolDisplayName } from './preflight/tools' +import { splitToolDisplayName, splitToolResourceId } from './preflight/tools' export const getPreseededToolContract = (world: DifyWorld, resourceName: string) => { const resource = world.agentBuilder.preflight.preseededResources[resourceName] @@ -10,11 +10,11 @@ export const getPreseededToolContract = (world: DifyWorld, resourceName: string) } const parsedDisplayName = splitToolDisplayName(resource.name) - const parsedToolId = splitToolDisplayName(resource.id) + const parsedToolId = splitToolResourceId(resource.id) if (!parsedDisplayName.ok) throw new Error(parsedDisplayName.reason) - if (!parsedToolId.ok) - throw new Error(parsedToolId.reason) + if (!parsedToolId.providerName || !parsedToolId.toolName) + throw new Error(`Preseeded tool "${resource.id}" must include provider and tool id segments.`) return { providerDisplayName: parsedDisplayName.providerName, diff --git a/e2e/features/agent-v2/tools.feature b/e2e/features/agent-v2/tools.feature index 305bf64fbc0..5feaac1ecc8 100644 --- a/e2e/features/agent-v2/tools.feature +++ b/e2e/features/agent-v2/tools.feature @@ -12,15 +12,34 @@ Feature: Agent v2 tools When I refresh the current page Then I should see the Agent v2 JSON Replace tool in the Tools section - @service-api-runtime @stable-model @tool-fixture + @core @oauth-tool-agent + Scenario: OAuth2 tool credentials stay authorized after Configure autosaves + Given I am signed in as the default E2E admin + And the Agent Builder preseeded Agent "E2E Agent With OAuth Tool" includes an OAuth2 tool credential + And an Agent v2 test agent with the OAuth2 tool credential fixture has been created via API + When I open the Agent v2 configure page + Then I should see the Agent v2 OAuth2 tool authorized in the Tools section + When I fill the Agent v2 prompt editor with the updated E2E prompt + Then the Agent v2 configuration should be saved automatically + And the Agent v2 OAuth2 tool credential should remain saved in the Agent v2 draft + When I refresh the current page + Then I should see the Agent v2 OAuth2 tool authorized in the Tools section + And the Agent v2 OAuth2 tool credential should remain saved in the Agent v2 draft + + @service-api-runtime @external-model @agent-backend-runtime @stable-model @tool-fixture Scenario: JSON Replace tool runtime returns the replacement marker Given I am signed in as the default E2E admin - And Agent v2 JSON Replace runtime verification is available + And the Agent v2 runtime backend is available And the Agent Builder stable chat model is available And the Agent Builder preseeded tool "JSON Process / JSON Replace" is available - And a runnable Agent v2 test agent has been created via API + And a runnable Agent v2 test agent with the JSON Replace tool has been created via API + And Agent v2 Backend service API access has been enabled with a key via API When I open the Agent v2 configure page - Then Agent v2 JSON Replace runtime verification should be available + Then the Agent v2 JSON Replace tool should be saved in the Agent v2 draft + When I publish the Agent v2 draft + Then the Agent v2 draft should be published and up to date + When I send the Agent v2 Backend service API JSON Replace request + Then the Agent v2 Backend service API response should include the JSON Replace E2E marker @core Scenario: Tool selector shows an empty state for a missing tool search diff --git a/e2e/features/step-definitions/agent-v2/access-point-helpers.ts b/e2e/features/step-definitions/agent-v2/access-point-helpers.ts index 0dc2f9ee2d6..18e249c71d9 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-helpers.ts @@ -26,11 +26,16 @@ export const getPreseededResource = ( export const getAccessRegion = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Access Point' }) +export type AccessSurfaceName = 'Web app' | 'Backend service API' + +export const getAccessSurfaceCard = (world: DifyWorld, surface: AccessSurfaceName) => + getAccessRegion(world).getByRole('article', { name: surface }).first() + export const getWebAppCard = (world: DifyWorld) => - getAccessRegion(world).locator('article').filter({ hasText: 'Web app' }).first() + getAccessSurfaceCard(world, 'Web app') export const getServiceApiCard = (world: DifyWorld) => - getAccessRegion(world).locator('article').filter({ hasText: 'Backend service API' }).first() + getAccessSurfaceCard(world, 'Backend service API') export const getDialog = (world: DifyWorld, name: string | RegExp) => world.getPage().getByRole('dialog', { name }) diff --git a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts index 0ea9e23a251..d23afca303d 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts @@ -18,15 +18,6 @@ async function enableAgentApiAccessWithKey(world: DifyWorld) { world.agentBuilder.accessPoint.generatedApiKey = apiKey.token } -Given( - 'Agent v2 Backend service API access has been enabled via API', - async function (this: DifyWorld) { - const apiAccess = await setAgentApiAccess(getCurrentAgentId(this), true) - - this.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url - }, -) - Given( 'Agent v2 Backend service API access has been enabled with a key via API', async function (this: DifyWorld) { @@ -181,26 +172,6 @@ Then('the Agent v2 API Reference should open in a new tab', async function (this this.agentBuilder.accessPoint.apiReferencePage = undefined }) -When('I disable Agent v2 Backend service API access', async function (this: DifyWorld) { - await getServiceApiCard(this).getByLabel('Toggle Backend service API access').click() -}) - -Then('Agent v2 Backend service API access should be out of service', async function (this: DifyWorld) { - const serviceApiCard = getServiceApiCard(this) - - await expect(serviceApiCard.getByText('Out of service')).toBeVisible({ timeout: 30_000 }) -}) - -When('I enable Agent v2 Backend service API access', async function (this: DifyWorld) { - await getServiceApiCard(this).getByLabel('Toggle Backend service API access').click() -}) - -Then('Agent v2 Backend service API access should be in service', async function (this: DifyWorld) { - const serviceApiCard = getServiceApiCard(this) - - await expect(serviceApiCard.getByText('In service')).toBeVisible({ timeout: 30_000 }) -}) - When('I send the Agent v2 Backend service API minimal request', async function (this: DifyWorld) { const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL const apiKey = this.agentBuilder.accessPoint.generatedApiKey @@ -225,11 +196,42 @@ When('I send the Agent v2 Backend service API knowledge request', async function this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({ apiKey, - query: agentBuilderFixedInputs.customKnowledgeQuery, + query: agentBuilderFixedInputs.knowledgeRuntimeQuery, serviceApiBaseURL, }) }) +const stringifyServiceApiBody = (body: unknown) => { + try { + return JSON.stringify(body) + } + catch { + return String(body) + } +} + +const expectServiceApiResponseOK = ( + response: NonNullable, + action: string, +) => { + if (response.ok) + return + + throw new Error(`${action} failed with ${response.status}: ${stringifyServiceApiBody(response.body)}`) +} + +const expectServiceApiResponseIncludes = ( + response: NonNullable, + expectedToken: string, + action: string, +) => { + expectServiceApiResponseOK(response, action) + + const body = stringifyServiceApiBody(response.body) + if (!body.includes(expectedToken)) + throw new Error(`${action} response did not include ${expectedToken}: ${body}`) +} + Then( 'the Agent v2 Backend service API request should be rejected while disabled', async function (this: DifyWorld) { @@ -250,8 +252,11 @@ Then( if (!response) throw new Error('No Agent v2 Backend service API response was recorded.') - expect(response.ok).toBe(true) - expect(JSON.stringify(response.body)).toContain(agentBuilderExpectedTokens.knowledgeReply) + expectServiceApiResponseIncludes( + response, + agentBuilderExpectedTokens.knowledgeReply, + 'Agent v2 Backend service API knowledge request', + ) }, ) @@ -262,7 +267,10 @@ Then( if (!response) throw new Error('No Agent v2 Backend service API response was recorded.') - expect(response.ok).toBe(true) - expect(JSON.stringify(response.body)).toContain(agentBuilderExpectedTokens.agentReply) + expectServiceApiResponseIncludes( + response, + agentBuilderExpectedTokens.agentReply, + 'Agent v2 Backend service API request', + ) }, ) diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index 34534edcbc8..d9d85e83772 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -1,7 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { setAgentSiteAccessAndGetURL } from '../../agent-v2/support/access-point' import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens } from '../../agent-v2/support/agent-builder-resources' import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' @@ -11,15 +10,7 @@ import { getWebAppCard, } from './access-point-helpers' -Given( - 'Agent v2 Web app access has been enabled via API', - async function (this: DifyWorld) { - this.agentBuilder.accessPoint.webAppURL = await setAgentSiteAccessAndGetURL( - getCurrentAgentId(this), - true, - ) - }, -) +const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000 Then('I should see the Agent v2 Web app access URL', async function (this: DifyWorld) { const webAppCard = getWebAppCard(this) @@ -101,6 +92,7 @@ Then('the Agent v2 Web app should open in a new tab', async function (this: Dify Then( 'the Agent v2 Web app response should include the updated E2E marker', + { timeout: WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS }, async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage if (!webAppPage) @@ -113,6 +105,7 @@ Then( Then( 'the Agent v2 Web app response should include the normal E2E marker', + { timeout: WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS }, async function (this: DifyWorld) { const webAppPage = this.agentBuilder.accessPoint.webAppPage if (!webAppPage) @@ -195,25 +188,6 @@ Then( }, ) -When('I disable Agent v2 Web app access', async function (this: DifyWorld) { - const webAppCard = getWebAppCard(this) - const launchLink = webAppCard.getByRole('link', { name: 'Launch' }) - const href = await launchLink.getAttribute('href') - if (!href) - throw new Error('Agent v2 Web app Launch link does not expose an href.') - - this.agentBuilder.accessPoint.webAppURL = href - - await webAppCard.getByLabel('Toggle Web app access').click() -}) - -Then('Agent v2 Web app access should be out of service', async function (this: DifyWorld) { - const webAppCard = getWebAppCard(this) - - await expect(webAppCard.getByText('Out of service')).toBeVisible() - await expect(webAppCard.getByRole('button', { name: 'Launch' })).toBeDisabled() -}) - Given( 'Agent v2 disabled Web app public unavailable state is available', async function (this: DifyWorld) { @@ -253,17 +227,6 @@ Then('the disabled Agent v2 Web app should show an unavailable state', async fun this.agentBuilder.accessPoint.webAppPage = undefined }) -When('I enable Agent v2 Web app access', async function (this: DifyWorld) { - await getWebAppCard(this).getByLabel('Toggle Web app access').click() -}) - -Then('Agent v2 Web app access should be in service', async function (this: DifyWorld) { - const webAppCard = getWebAppCard(this) - - await expect(webAppCard.getByText('In service')).toBeVisible() - await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible() -}) - When('I open the restored Agent v2 Web app URL', async function (this: DifyWorld) { const webAppURL = this.agentBuilder.accessPoint.webAppURL if (!webAppURL) diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index 00f99593719..40c7e852aa3 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -1,9 +1,15 @@ import type { DifyWorld } from '../../support/world' +import type { AccessSurfaceName } from './access-point-helpers' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import { + setAgentApiAccess, + setAgentSiteAccessAndGetURL, +} from '../../agent-v2/support/access-point' import { getAgentAccessPath, publishAgent } from '../../agent-v2/support/agent' import { getAccessRegion, + getAccessSurfaceCard, getCurrentAgentId, getPreseededResource, } from './access-point-helpers' @@ -12,6 +18,22 @@ Given('the Agent v2 draft has been published via API', async function (this: Dif await publishAgent(getCurrentAgentId(this)) }) +Given( + /^Agent v2 (Web app|Backend service API) access has been enabled via API$/, + async function (this: DifyWorld, surface: AccessSurfaceName) { + if (surface === 'Web app') { + this.agentBuilder.accessPoint.webAppURL = await setAgentSiteAccessAndGetURL( + getCurrentAgentId(this), + true, + ) + return + } + + const apiAccess = await setAgentApiAccess(getCurrentAgentId(this), true) + this.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url + }, +) + When('I open the Agent v2 Access Point page', async function (this: DifyWorld) { await this.getPage().goto(getAgentAccessPath(getCurrentAgentId(this))) }) @@ -70,3 +92,50 @@ Then('I should see the Agent v2 Access Point overview', async function (this: Di await expect(accessRegion.getByRole('columnheader', { name: 'Actions' })).toBeVisible() await expect(accessRegion.getByText('No workflow references yet.')).toBeVisible() }) + +When( + /^I disable Agent v2 (Web app|Backend service API) access$/, + async function (this: DifyWorld, surface: AccessSurfaceName) { + const accessSurfaceCard = getAccessSurfaceCard(this, surface) + + if (surface === 'Web app') { + const launchLink = accessSurfaceCard.getByRole('link', { name: 'Launch' }) + const href = await launchLink.getAttribute('href') + if (!href) + throw new Error('Agent v2 Web app Launch link does not expose an href.') + + this.agentBuilder.accessPoint.webAppURL = href + } + + await accessSurfaceCard.getByLabel(`Toggle ${surface} access`).click() + }, +) + +When( + /^I enable Agent v2 (Web app|Backend service API) access$/, + async function (this: DifyWorld, surface: AccessSurfaceName) { + await getAccessSurfaceCard(this, surface).getByLabel(`Toggle ${surface} access`).click() + }, +) + +Then( + /^Agent v2 (Web app|Backend service API) access should be out of service$/, + async function (this: DifyWorld, surface: AccessSurfaceName) { + const accessSurfaceCard = getAccessSurfaceCard(this, surface) + + await expect(accessSurfaceCard.getByText('Out of service')).toBeVisible({ timeout: 30_000 }) + if (surface === 'Web app') + await expect(accessSurfaceCard.getByRole('button', { name: 'Launch' })).toBeDisabled() + }, +) + +Then( + /^Agent v2 (Web app|Backend service API) access should be in service$/, + async function (this: DifyWorld, surface: AccessSurfaceName) { + const accessSurfaceCard = getAccessSurfaceCard(this, surface) + + await expect(accessSurfaceCard.getByText('In service')).toBeVisible({ timeout: 30_000 }) + if (surface === 'Web app') + await expect(accessSurfaceCard.getByRole('link', { name: 'Launch' })).toBeVisible() + }, +) diff --git a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts index 99e69c1ba13..70fd03195e3 100644 --- a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts @@ -16,8 +16,8 @@ When('I create an Agent v2 test agent from the Agent Roster', async function (th const dialog = page.getByRole('dialog', { name: 'Create agent' }) await expect(dialog).toBeVisible() await dialog.getByRole('textbox', { name: 'Name' }).fill(agentName) - await dialog.getByRole('textbox', { name: 'Role' }).fill(agentRole) - await dialog.getByRole('textbox', { name: 'Description' }).fill(agentDescription) + await dialog.getByRole('textbox', { name: /Role.*optional/ }).fill(agentRole) + await dialog.getByRole('textbox', { name: /Description.*optional/ }).fill(agentDescription) const createResponsePromise = page.waitForResponse(response => ( response.request().method() === 'POST' diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index 163932deb10..b9e5ff7335b 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -27,6 +27,8 @@ import { uploadSummaryConfigSkillForBuildDraft, } from './configure-helpers' +const BUILD_DRAFT_RUNTIME_STEP_TIMEOUT_MS = 180_000 + Given( 'an Agent v2 Build draft adds the supported E2E files, skills, and env', async function (this: DifyWorld) { @@ -108,30 +110,34 @@ Given( }, ) -When('I generate an Agent v2 Build draft from the fixed instruction', async function (this: DifyWorld) { - const page = this.getPage() - const agentId = getCurrentAgentId(this) - const instruction = (await readFile(getAgentBuilderTestMaterialPath('buildInstruction'), 'utf8')).trim() +When( + 'I generate an Agent v2 Build draft from the fixed instruction', + { timeout: 180_000 }, + async function (this: DifyWorld) { + const page = this.getPage() + const agentId = getCurrentAgentId(this) + const instruction = (await readFile(getAgentBuilderTestMaterialPath('buildInstruction'), 'utf8')).trim() - await page.getByRole('button', { name: 'Build' }).click() - await page.getByPlaceholder('Describe what your agent should do').fill(instruction) + await page.getByRole('button', { exact: true, name: 'Build' }).click() + await page.getByPlaceholder('Describe what your agent should do').fill(instruction) - const checkoutResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft/checkout`) - )) - const chatResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/chat-messages`) - )) + const checkoutResponsePromise = page.waitForResponse(response => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft/checkout`) + )) + const chatResponsePromise = page.waitForResponse(response => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/chat-messages`) + )) - await page.getByRole('button', { name: 'Start build' }).click() - expect((await checkoutResponsePromise).ok()).toBe(true) - expect((await chatResponsePromise).ok()).toBe(true) - await expect(page.getByText('Build draft')).toBeVisible({ timeout: 120_000 }) - await expect(page.getByRole('button', { name: 'Apply' })).toBeEnabled({ timeout: 120_000 }) - await expect(page.getByRole('button', { name: 'Discard' })).toBeEnabled() -}) + await page.getByRole('button', { name: 'Start build' }).click() + expect((await checkoutResponsePromise).ok()).toBe(true) + expect((await chatResponsePromise).ok()).toBe(true) + await expect(page.getByText('Build draft')).toBeVisible({ timeout: 120_000 }) + await expect(page.getByRole('button', { exact: true, name: 'Apply' })).toBeEnabled({ timeout: 120_000 }) + await expect(page.getByRole('button', { exact: true, name: 'Discard' })).toBeEnabled() + }, +) const expectPageResponseOK = async (response: Response, action: string) => { if (response.ok()) @@ -150,34 +156,38 @@ const expectPageResponseOK = async (response: Response, action: string) => { } When('I discard the Agent v2 Build draft', async function (this: DifyWorld) { - await this.getPage().getByRole('button', { name: 'Discard' }).click() + await this.getPage().getByRole('button', { exact: true, name: 'Discard' }).click() }) -When('I apply the Agent v2 Build draft', async function (this: DifyWorld) { - const page = this.getPage() - const agentId = getCurrentAgentId(this) - const applyButton = page.getByRole('button', { name: 'Apply' }) +When( + 'I apply the Agent v2 Build draft', + { timeout: BUILD_DRAFT_RUNTIME_STEP_TIMEOUT_MS }, + async function (this: DifyWorld) { + const page = this.getPage() + const agentId = getCurrentAgentId(this) + const applyButton = page.getByRole('button', { exact: true, name: 'Apply' }) - await expect(applyButton).toBeEnabled({ timeout: 30_000 }) - const finalizeResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-chat/finalize`) - ), { timeout: 120_000 }) - const applyResponsePromise = page.waitForResponse(response => ( - response.request().method() === 'POST' - && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft/apply`) - ), { timeout: 120_000 }) + await expect(applyButton).toBeEnabled({ timeout: 30_000 }) + const finalizeResponsePromise = page.waitForResponse(response => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-chat/finalize`) + ), { timeout: 120_000 }) + const applyResponsePromise = page.waitForResponse(response => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft/apply`) + ), { timeout: 120_000 }) - await applyButton.click() - await expectPageResponseOK(await finalizeResponsePromise, 'Finalize Agent v2 Build draft') - await expectPageResponseOK(await applyResponsePromise, 'Apply Agent v2 Build draft') - await expect(page.getByText('Action succeeded')).toBeVisible() -}) + await applyButton.click() + await expectPageResponseOK(await finalizeResponsePromise, 'Finalize Agent v2 Build draft') + await expectPageResponseOK(await applyResponsePromise, 'Apply Agent v2 Build draft') + await expect(page.getByText('Action succeeded')).toBeVisible() + }, +) async function skipBuildDraftToolWriteback(world: DifyWorld) { return skipBlockedPrecondition( world, - 'Build draft Dify Tool writeback is not available: Build draft currently supports files, skills, and env only.', + 'Build chat Dify Tool writeback is not available: finalize/config mutation paths do not support tools; current passing coverage only verifies API-seeded Build draft apply for files, skills, and env.', { owner: 'product', remediation: 'Define and implement Build draft Tool writeback before enabling this scenario.', @@ -216,8 +226,8 @@ Then('I should see the Agent v2 Build draft pending changes', async function (th const page = this.getPage() await expect(page.getByText('Build draft')).toBeVisible({ timeout: 30_000 }) - await expect(page.getByRole('button', { name: 'Apply' })).toBeEnabled() - await expect(page.getByRole('button', { name: 'Discard' })).toBeEnabled() + await expect(page.getByRole('button', { exact: true, name: 'Apply' })).toBeEnabled() + await expect(page.getByRole('button', { exact: true, name: 'Discard' })).toBeEnabled() }) Then('I should see the Agent v2 Build mode confirmation state', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index 7d2af762d5d..bf9d4f0dfce 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -100,6 +100,27 @@ Given('a runnable Agent v2 test agent has been created via API', async function this.lastCreatedAgentRole = agent.role ?? undefined }) +Given( + 'a runnable Agent v2 test agent using the agent-decision model has been created via API', + async function (this: DifyWorld) { + if (!this.agentBuilder.preflight.agentDecisionModel) { + throw new Error( + 'Create an agent-decision Agent v2 test agent after agent-decision model preflight.', + ) + } + + const agent = await createConfiguredTestAgent({ + agentSoul: createAgentSoulConfigWithModel( + normalAgentSoulConfig, + this.agentBuilder.preflight.agentDecisionModel, + ), + }) + this.createdAgentIds.push(agent.id) + this.lastCreatedAgentName = agent.name + this.lastCreatedAgentRole = agent.role ?? undefined + }, +) + Given('a minimal Agent v2 composer draft has been synced', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) diff --git a/e2e/features/step-definitions/agent-v2/files.steps.ts b/e2e/features/step-definitions/agent-v2/files.steps.ts index e8780f5a032..76f8eaf6617 100644 --- a/e2e/features/step-definitions/agent-v2/files.steps.ts +++ b/e2e/features/step-definitions/agent-v2/files.steps.ts @@ -2,7 +2,10 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' -import { agentBuilderFileTreeFixtureFileNames } from '../../agent-v2/support/test-materials' +import { + agentBuilderFileTreeFixtureFileNames, + agentBuilderTestMaterials, +} from '../../agent-v2/support/test-materials' import { expectAgentConfigFileHidden, expectAgentConfigFileSaved, @@ -22,6 +25,51 @@ When('I upload the special-name Agent v2 file from the Files section', async fun await uploadAgentConfigFile(this, 'specialFilename') }) +When('I drop multiple Agent v2 files into the Files upload dialog', async function (this: DifyWorld) { + const page = this.getPage() + + await page.getByRole('button', { name: 'Add file' }).click() + const dialog = page.getByRole('dialog', { name: 'Upload file' }) + await expect(dialog).toBeVisible() + + const dropZone = dialog.getByRole('group', { name: 'Upload file' }) + await expect(dropZone).toBeVisible() + + const droppedFileNames: [string, string] = [ + agentBuilderTestMaterials.smallFile, + agentBuilderTestMaterials.emptyFile, + ] + const dataTransfer = await page.evaluateHandle(([smallFileName, emptyFileName]: [string, string]) => { + const transfer = new DataTransfer() + transfer.items.add(new File(['small agent file'], smallFileName, { type: 'text/plain' })) + transfer.items.add(new File([''], emptyFileName, { type: 'text/plain' })) + return transfer + }, droppedFileNames) + + await dropZone.dispatchEvent('dragenter', { dataTransfer }) + await dropZone.dispatchEvent('dragover', { dataTransfer }) + await dropZone.dispatchEvent('drop', { dataTransfer }) + await dataTransfer.dispose() +}) + +Then( + 'the Agent v2 Files upload dialog should reject the multiple-file drop', + async function (this: DifyWorld) { + const page = this.getPage() + const dialog = page.getByRole('dialog', { name: 'Upload file' }) + + await expect(page.getByText('Upload one file.')).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Upload' })).toBeDisabled() + await expect(dialog.getByText(agentBuilderTestMaterials.smallFile, { exact: true })).not.toBeVisible() + await expect(dialog.getByText(agentBuilderTestMaterials.emptyFile, { exact: true })).not.toBeVisible() + }, +) + +Then('I should not see the dropped Agent v2 files in the Files section', async function (this: DifyWorld) { + await expectAgentConfigFileHidden(this, 'smallFile') + await expectAgentConfigFileHidden(this, 'emptyFile') +}) + Then( 'I should see the Agent v2 file fixture entries in the current flat Files list', async function (this: DifyWorld) { @@ -120,25 +168,6 @@ Then('Agent v2 oversized file rejection should be available', async function (th return skipOversizedFileRejection(this) }) -async function skipSingleBatchFileCountLimits(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 single-batch file count limits are not reachable: the current Agent config file upload dialog accepts one file per upload.', - { - owner: 'product', - remediation: 'Define multi-file upload behavior and its count-limit error before enabling this scenario.', - }, - ) -} - -Given('Agent v2 single-batch file count limits are available', async function (this: DifyWorld) { - return skipSingleBatchFileCountLimits(this) -}) - -Then('Agent v2 single-batch file count limits should be available', async function (this: DifyWorld) { - return skipSingleBatchFileCountLimits(this) -}) - async function skipTotalFileCountLimits(world: DifyWorld) { return skipBlockedPrecondition( world, diff --git a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts index fad39fac655..9cdf210248c 100644 --- a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts +++ b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts @@ -107,7 +107,7 @@ const expectKnowledgeRetrievalDraft = async ( mode: query.mode, name: knowledgeSet.name, retrievalMode: retrieval.mode, - value: query.value, + value: query.value ?? undefined, } }, { timeout: 30_000 }, @@ -149,6 +149,8 @@ When( await selectPreseededKnowledgeBase(this, dialog) await expect(dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true })) .toBeVisible() + await this.getPage().keyboard.press('Escape') + await expect(dialog).toBeHidden() }, ) @@ -164,6 +166,8 @@ When( await selectPreseededKnowledgeBase(this, dialog) await expect(dialog.getByText(getPreseededKnowledgeBase(this).name, { exact: true })) .toBeVisible() + await this.getPage().keyboard.press('Escape') + await expect(dialog).toBeHidden() }, ) diff --git a/e2e/features/step-definitions/agent-v2/preflight.steps.ts b/e2e/features/step-definitions/agent-v2/preflight.steps.ts index 09080d7a873..bd62425ba34 100644 --- a/e2e/features/step-definitions/agent-v2/preflight.steps.ts +++ b/e2e/features/step-definitions/agent-v2/preflight.steps.ts @@ -5,6 +5,7 @@ import { skipMissingPreseededAgentPublishedWebApp, skipMissingPreseededAgentWorkflowReference, } from '../../agent-v2/support/preflight/access' +import { skipMissingAgentBackendRuntime } from '../../agent-v2/support/preflight/agent-backend' import { skipMissingPreseededAgent, skipMissingPreseededAgentDriveSkill, @@ -12,6 +13,7 @@ import { skipMissingPreseededAgentFlatFileFixtureConfiguration, skipMissingPreseededDualRetrievalAgentConfiguration, skipMissingPreseededFullConfigAgentCoreConfiguration, + skipMissingPreseededOAuthToolAgentConfiguration, skipMissingPreseededToolStatesAgentConfiguration, skipMissingPreseededWorkflow, } from '../../agent-v2/support/preflight/agents' @@ -21,6 +23,7 @@ import { skipMissingReadyPreseededDataset, } from '../../agent-v2/support/preflight/datasets' import { + skipMissingAgentBuilderAgentDecisionChatModel, skipMissingAgentBuilderBrokenChatModel, skipMissingAgentBuilderStableChatModel, } from '../../agent-v2/support/preflight/models' @@ -34,6 +37,14 @@ Given('the Agent Builder stable chat model is available', async function (this: this.agentBuilder.preflight.stableModel = stableModel }) +Given('the Agent Builder agent-decision chat model is available', async function (this: DifyWorld) { + const agentDecisionModel = await skipMissingAgentBuilderAgentDecisionChatModel(this) + if (agentDecisionModel === 'skipped') + return agentDecisionModel + + this.agentBuilder.preflight.agentDecisionModel = agentDecisionModel +}) + Given('the Agent Builder broken chat model is available', async function (this: DifyWorld) { const brokenModel = await skipMissingAgentBuilderBrokenChatModel(this) if (brokenModel === 'skipped') @@ -42,6 +53,12 @@ Given('the Agent Builder broken chat model is available', async function (this: this.agentBuilder.preflight.brokenModel = brokenModel }) +Given('the Agent v2 runtime backend is available', async function (this: DifyWorld) { + const runtimeBackend = await skipMissingAgentBackendRuntime(this) + if (runtimeBackend === 'skipped') + return runtimeBackend +}) + Given( 'the Agent Builder preseeded Agent {string} is available', async function (this: DifyWorld, resourceName: string) { @@ -142,6 +159,18 @@ Given( }, ) +Given( + 'the Agent Builder preseeded Agent {string} includes an OAuth2 tool credential', + async function (this: DifyWorld, agentName: string) { + const resource = await skipMissingPreseededOAuthToolAgentConfiguration(this, agentName) + if (resource === 'skipped') + return resource + + this.agentBuilder.preflight.preseededResources[`${agentName} / OAuth2 tool credential`] + = resource + }, +) + Given( 'the Agent Builder preseeded Agent {string} includes the dual retrieval fixture configuration', async function (this: DifyWorld, agentName: string) { diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index 889d4b2a3a1..78eab7a488a 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -1,9 +1,13 @@ +import type { AgentSoulDifyToolConfig } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' -import { agentBuilderFixedInputs, agentBuilderPreseededResources } from '../../agent-v2/support/agent-builder-resources' -import { asArray, asRecord, skipBlockedPrecondition } from '../../agent-v2/support/preflight/common' +import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' +import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' +import { agentBuilderExpectedTokens, agentBuilderFixedInputs, agentBuilderPreseededResources } from '../../agent-v2/support/agent-builder-resources' +import { createAgentSoulConfigWithDifyTool, createAgentSoulConfigWithModel, normalAgentSoulConfig } from '../../agent-v2/support/agent-soul' +import { getPreseededOAuthToolConfig } from '../../agent-v2/support/preflight/agents' +import { asArray, asRecord, asString } from '../../agent-v2/support/preflight/common' import { hasToolEntry } from '../../agent-v2/support/preflight/tools' import { getPreseededToolContract } from '../../agent-v2/support/tools' import { expectProviderToolActionVisible, getCurrentAgentId } from './configure-helpers' @@ -14,6 +18,14 @@ const getToolsSection = (world: DifyWorld) => const getToolSelectorSearch = (world: DifyWorld) => world.getPage().getByRole('textbox', { name: 'Search integrations...' }) +const jsonReplaceRuntimePrompt = [ + 'You are a Dify Agent E2E JSON tool verifier.', + 'When the user asks to verify JSON Replace, call the JSON Replace tool exactly once.', + `Use content {"marker":"${agentBuilderExpectedTokens.jsonToolBefore}","nested":{"status":"keep"}}.`, + 'Use query $.marker and replace_value E2E_AFTER.', + 'After the tool returns, answer with the resulting JSON and include E2E_AFTER.', +].join(' ') + const expectJsonReplaceToolDraft = async (world: DifyWorld) => { const agentId = getCurrentAgentId(world) const tool = getPreseededToolContract(world, agentBuilderPreseededResources.jsonReplaceTool) @@ -29,17 +41,176 @@ const expectJsonReplaceToolDraft = async (world: DifyWorld) => { ).toBe(true) } -async function skipJsonReplaceRuntimeVerification(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 JSON Replace runtime verification is blocked: the suite needs the JSON Process / JSON Replace runtime parameter contract and a deterministic published-runtime prompt before asserting tool execution.', - { - owner: 'test/seed', - remediation: 'Seed the JSON Replace tool runtime contract, then verify execution through published Web app or Backend service API instead of Builder Preview.', - }, - ) +const getOAuth2ToolEntries = async (agentId: string) => { + const draft = await getAgentComposerDraft(agentId) + + return asArray(asRecord(draft.agent_soul?.tools).dify_tools).filter((item) => { + const record = asRecord(item) + + return record.credential_type === 'oauth2' + && Boolean(asString(asRecord(record.credential_ref).id)) + }) } +const getOAuth2ToolDisplayName = async (world: DifyWorld) => { + const [tool] = await getOAuth2ToolEntries(getCurrentAgentId(world)) + const record = asRecord(tool) + const providerName = asString(record.provider) || asString(record.provider_id) || asString(record.plugin_id) + const toolName = asString(record.name) || asString(record.tool_name) + + if (!providerName || !toolName) + throw new Error('Agent v2 OAuth2 tool fixture must include provider and tool names.') + + return `${providerName} / ${toolName}` +} + +const getPreseededOAuthToolAgent = (world: DifyWorld) => { + const resource = world.agentBuilder.preflight.preseededResources[ + `${agentBuilderPreseededResources.oauthToolAgent} / OAuth2 tool credential` + ] + if (!resource || resource.kind !== 'agent') { + throw new Error( + `Preseeded Agent "${agentBuilderPreseededResources.oauthToolAgent}" OAuth2 tool credential fixture is not available. Run the matching preflight step first.`, + ) + } + + return resource +} + +const jsonReplaceToolConfig = (world: DifyWorld): AgentSoulDifyToolConfig => { + const tool = getPreseededToolContract(world, agentBuilderPreseededResources.jsonReplaceTool) + + return { + credential_type: 'unauthorized', + enabled: true, + provider: tool.providerDisplayName, + provider_id: tool.providerName, + provider_type: 'builtin', + runtime_parameters: { + ensure_ascii: true, + replace_model: 'value', + value_decode: false, + }, + tool_name: tool.toolName, + } +} + +const getServiceApiSseEvents = (body: unknown) => + asArray(asRecord(body).events).map(asRecord) + +const getServiceApiEventData = (event: Record) => asRecord(event.data) + +const getServiceApiEventName = (event: Record) => { + const data = getServiceApiEventData(event) + + return asString(data.event) || asString(event.event) +} + +const summarizeServiceApiRuntimeEvents = (body: unknown) => + getServiceApiSseEvents(body).map((event, index) => { + const data = getServiceApiEventData(event) + const observation = asString(data.observation) + const answer = asString(data.answer) + + return { + index, + event: getServiceApiEventName(event), + tool: asString(data.tool), + hasToolInput: Boolean(asString(data.tool_input)), + hasObservation: Boolean(observation), + observation: observation.slice(0, 180), + answer: answer.slice(0, 180), + } + }) + +const findJsonReplaceRuntimeThought = (body: unknown) => + getServiceApiSseEvents(body).find((event) => { + const data = getServiceApiEventData(event) + const toolInput = asString(data.tool_input) + const observation = asString(data.observation) + + return getServiceApiEventName(event) === 'agent_thought' + && asString(data.tool) === 'json_replace' + && toolInput.includes(agentBuilderExpectedTokens.jsonToolBefore) + && toolInput.includes('$.marker') + && observation.includes(agentBuilderExpectedTokens.jsonToolAfter) + }) + +const expectOAuth2CredentialPreserved = async (world: DifyWorld) => { + const preseededAgent = getPreseededOAuthToolAgent(world) + const expectedTool = await getPreseededOAuthToolConfig(preseededAgent.id) + const expected = asRecord(expectedTool) + const expectedCredentialRef = asRecord(expected.credential_ref) + const expectedCredentialId = asString(expectedCredentialRef.id) + const expectedProvider = asString(expected.provider_id) || asString(expected.provider) || asString(expected.plugin_id) + const expectedToolName = asString(expected.tool_name) || asString(expected.name) + + await expect.poll( + async () => { + const tools = await getOAuth2ToolEntries(getCurrentAgentId(world)) + const matchingTool = tools.find((item) => { + const record = asRecord(item) + const provider = asString(record.provider_id) || asString(record.provider) || asString(record.plugin_id) + const toolName = asString(record.tool_name) || asString(record.name) + + return provider === expectedProvider && toolName === expectedToolName + }) + const record = asRecord(matchingTool) + + return { + credentialId: asString(asRecord(record.credential_ref).id), + credentialType: asString(record.credential_type), + } + }, + { timeout: 30_000 }, + ).toEqual({ + credentialId: expectedCredentialId, + credentialType: 'oauth2', + }) +} + +Given( + 'an Agent v2 test agent with the OAuth2 tool credential fixture has been created via API', + async function (this: DifyWorld) { + const preseededAgent = getPreseededOAuthToolAgent(this) + const oauthTool = await getPreseededOAuthToolConfig(preseededAgent.id) + const agent = await createConfiguredTestAgent({ + agentSoul: createAgentSoulConfigWithDifyTool(normalAgentSoulConfig, oauthTool), + }) + + this.createdAgentIds.push(agent.id) + this.lastCreatedAgentName = agent.name + this.lastCreatedAgentRole = agent.role ?? undefined + }, +) + +Given( + 'a runnable Agent v2 test agent with the JSON Replace tool has been created via API', + async function (this: DifyWorld) { + if (!this.agentBuilder.preflight.stableModel) + throw new Error('Create a JSON Replace runtime Agent after stable model preflight.') + + const agent = await createConfiguredTestAgent({ + agentSoul: createAgentSoulConfigWithDifyTool( + createAgentSoulConfigWithModel( + { + ...normalAgentSoulConfig, + prompt: { + system_prompt: jsonReplaceRuntimePrompt, + }, + }, + this.agentBuilder.preflight.stableModel, + ), + jsonReplaceToolConfig(this), + ), + }) + + this.createdAgentIds.push(agent.id) + this.lastCreatedAgentName = agent.name + this.lastCreatedAgentRole = agent.role ?? undefined + }, +) + When( 'I add the Agent Builder JSON Replace tool from the Tools selector', async function (this: DifyWorld) { @@ -83,14 +254,6 @@ When('I clear the Agent v2 tool selector search', async function (this: DifyWorl await search.fill('') }) -Given('Agent v2 JSON Replace runtime verification is available', async function (this: DifyWorld) { - return skipJsonReplaceRuntimeVerification(this) -}) - -Then('Agent v2 JSON Replace runtime verification should be available', async function (this: DifyWorld) { - return skipJsonReplaceRuntimeVerification(this) -}) - Then( 'the Agent v2 JSON Replace tool should be saved in the Agent v2 draft', async function (this: DifyWorld) { @@ -109,6 +272,72 @@ Then( }, ) +When('I send the Agent v2 Backend service API JSON Replace request', async function (this: DifyWorld) { + const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL + const apiKey = this.agentBuilder.accessPoint.generatedApiKey + if (!serviceApiBaseURL) + throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.') + if (!apiKey) + throw new Error('No Agent v2 API key found. Create a Backend service API key first.') + + this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({ + apiKey, + query: [ + 'Verify JSON Replace now.', + `Replace ${agentBuilderExpectedTokens.jsonToolBefore} with ${agentBuilderExpectedTokens.jsonToolAfter}.`, + `Return the JSON result and include ${agentBuilderExpectedTokens.jsonToolAfter}.`, + ].join(' '), + serviceApiBaseURL, + }) +}) + +Then( + 'the Agent v2 Backend service API response should include the JSON Replace E2E marker', + async function (this: DifyWorld) { + const response = this.agentBuilder.accessPoint.serviceApiResponse + if (!response) + throw new Error('No Agent v2 Backend service API response was recorded.') + if (!response.ok) + throw new Error(`Agent v2 Backend service API JSON Replace request failed with ${response.status}: ${JSON.stringify(response.body)}`) + + const jsonReplaceThought = findJsonReplaceRuntimeThought(response.body) + if (!jsonReplaceThought) { + throw new Error( + [ + 'Agent v2 Backend service API did not emit a JSON Replace agent_thought with matching tool input and observation.', + `Received events: ${JSON.stringify(summarizeServiceApiRuntimeEvents(response.body))}`, + ].join('\n'), + ) + } + + const thought = getServiceApiEventData(jsonReplaceThought) + expect(asString(thought.tool_input)).toContain(agentBuilderExpectedTokens.jsonToolBefore) + expect(asString(thought.tool_input)).toContain('$.marker') + expect(asString(thought.observation)).toContain(agentBuilderExpectedTokens.jsonToolAfter) + expect(asString(asRecord(response.body).answer)).toContain(agentBuilderExpectedTokens.jsonToolAfter) + }, +) + +Then( + 'I should see the Agent v2 OAuth2 tool authorized in the Tools section', + async function (this: DifyWorld) { + const toolsSection = getToolsSection(this) + const displayName = await getOAuth2ToolDisplayName(this) + + await expectProviderToolActionVisible(toolsSection, displayName) + await expect(toolsSection.getByRole('button', { exact: true, name: 'Not authorized' })) + .not + .toBeVisible() + }, +) + +Then( + 'the Agent v2 OAuth2 tool credential should remain saved in the Agent v2 draft', + async function (this: DifyWorld) { + await expectOAuth2CredentialPreserved(this) + }, +) + Then('I should see the Agent v2 tool selector empty state', async function (this: DifyWorld) { const page = this.getPage() diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index 20d9c05522e..eacc49f4ead 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -9,6 +9,8 @@ import { } from '../../../support/api' import { createE2EResourceName } from '../../../support/naming' +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + When('I enable the Web App share', async function (this: DifyWorld) { const page = this.getPage() const appName = this.lastCreatedAppName @@ -18,7 +20,7 @@ When('I enable the Web App share', async function (this: DifyWorld) { ) } - await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click() + await page.getByRole('button', { name: new RegExp(escapeRegExp(appName)) }).click() await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 }) await page.getByRole('switch').first().click() }) @@ -47,7 +49,7 @@ When('I open the shared app URL', async function (this: DifyWorld) { Then('the shared app page should be accessible', async function (this: DifyWorld) { await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 }) - await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_000 }) + await expect(this.getPage().getByRole('button', { name: 'Execute' })).toBeVisible({ timeout: 10_000 }) }) When('I run the shared workflow app', async function (this: DifyWorld) { @@ -59,5 +61,5 @@ When('I run the shared workflow app', async function (this: DifyWorld) { }) Then('the shared workflow run should succeed', async function (this: DifyWorld) { - await expect(this.getPage().getByTestId('status-icon-success')).toBeVisible({ timeout: 55_000 }) + await expect(this.getPage().getByRole('img', { name: 'Workflow Process succeeded' })).toBeVisible({ timeout: 55_000 }) }) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 284e8806f75..689275c9ccf 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -22,7 +22,7 @@ export type CreatedBuiltinToolCredential = { credentialId: string provider: string } -export type AgentBuilderStableChatModel = { +export type AgentBuilderChatModel = { name: string provider: string type: string @@ -39,9 +39,10 @@ export type AgentV2WorkflowOutputVariable = { export const createAgentBuilderWorldState = () => ({ preflight: { - brokenModel: undefined as AgentBuilderStableChatModel | undefined, + agentDecisionModel: undefined as AgentBuilderChatModel | undefined, + brokenModel: undefined as AgentBuilderChatModel | undefined, preseededResources: {} as Record, - stableModel: undefined as AgentBuilderStableChatModel | undefined, + stableModel: undefined as AgentBuilderChatModel | undefined, }, accessPoint: { apiReferencePage: undefined as Page | undefined, diff --git a/e2e/fixtures/test-materials/agent-build-instruction.txt b/e2e/fixtures/test-materials/agent-build-instruction.txt index d5cf5f83e5e..82ebf01d3a7 100644 --- a/e2e/fixtures/test-materials/agent-build-instruction.txt +++ b/e2e/fixtures/test-materials/agent-build-instruction.txt @@ -1,3 +1,3 @@ -Ask the Agent to use e2e-summary-skill to summarize user input. -When replacing JSON strings, use JSON Process / JSON Replace. -After applying, include these capabilities in the Agent instructions. +Update only the Agent instructions. +After applying, every response should briefly mention E2E_BUILD_DRAFT_PASS. +Do not add files, skills, tools, knowledge, environment variables, or other capabilities. diff --git a/e2e/fixtures/test-materials/agent-knowledge-source.txt b/e2e/fixtures/test-materials/agent-knowledge-source.txt index 2275246ac5b..fd5f4299175 100644 --- a/e2e/fixtures/test-materials/agent-knowledge-source.txt +++ b/e2e/fixtures/test-materials/agent-knowledge-source.txt @@ -1,2 +1,3 @@ Dify Agent E2E knowledge source. -Dify Agent E2E test passphrase is AGENT_KNOWLEDGE_PASS. +The Dify Agent E2E knowledge marker is AGENT_KNOWLEDGE_PASS. +Use the connected knowledge source to find the Dify Agent E2E knowledge marker. diff --git a/e2e/package.json b/e2e/package.json index 9210fc6a81b..c0804956f31 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "e2e": "tsx ./scripts/run-cucumber.ts", + "e2e:external": "tsx ./scripts/run-external-runtime.ts", + "e2e:external:prepare": "tsx ./scripts/prepare-external-runtime.ts", "e2e:full": "tsx ./scripts/run-cucumber.ts --full", "e2e:full:headed": "tsx ./scripts/run-cucumber.ts --full --headed", "e2e:headed": "tsx ./scripts/run-cucumber.ts --headed", @@ -11,6 +13,7 @@ "e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down", "e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up", "e2e:reset": "tsx ./scripts/setup.ts reset", + "seed": "tsx ./scripts/seed.ts", "type-check": "tsgo" }, "devDependencies": { @@ -18,11 +21,13 @@ "@dify/contracts": "workspace:*", "@dify/tsconfig": "workspace:*", "@playwright/test": "catalog:", + "@t3-oss/env-core": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "tsx": "catalog:", "typescript": "catalog:", "vite": "catalog:", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "zod": "catalog:" } } diff --git a/e2e/scripts/common.ts b/e2e/scripts/common.ts index 5e5edca70ce..be507b62abf 100644 --- a/e2e/scripts/common.ts +++ b/e2e/scripts/common.ts @@ -32,6 +32,7 @@ type ForegroundProcessOptions = { export const rootDir = fileURLToPath(new URL('../..', import.meta.url)) export const e2eDir = path.join(rootDir, 'e2e') export const apiDir = path.join(rootDir, 'api') +export const difyAgentDir = path.join(rootDir, 'dify-agent') export const dockerDir = path.join(rootDir, 'docker') export const webDir = path.join(rootDir, 'web') @@ -207,7 +208,7 @@ export const getWebEnvLocalHash = async () => { .digest('hex') } -export const readSimpleDotenv = async (filePath: string) => { +export const readSimpleDotenv = async (filePath: string): Promise> => { const fileContent = await readFile(filePath, 'utf8') const entries = fileContent .split(/\r?\n/) diff --git a/e2e/scripts/env-register.ts b/e2e/scripts/env-register.ts new file mode 100644 index 00000000000..b4e69651d8b --- /dev/null +++ b/e2e/scripts/env-register.ts @@ -0,0 +1,5 @@ +import { loadE2eEnv, validateE2eEnv } from './env' + +// Entry points import this before test-env.ts so e2e/.env.local can affect base URLs and runner flags. +loadE2eEnv() +validateE2eEnv() diff --git a/e2e/scripts/env.ts b/e2e/scripts/env.ts new file mode 100644 index 00000000000..381eada1ecc --- /dev/null +++ b/e2e/scripts/env.ts @@ -0,0 +1,150 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { createEnv } from '@t3-oss/env-core' +import * as z from 'zod' +import { e2eDir } from './common' + +const booleanString = z.enum(['0', '1', 'false', 'true']) +const jsonObjectString = z.string().refine((value) => { + try { + const parsed = JSON.parse(value) as unknown + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + } + catch { + return false + } +}, 'must be a JSON object string') + +const parseEnvLine = (line: string) => { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) + return undefined + + const normalized = trimmed.startsWith('export ') + ? trimmed.slice('export '.length).trim() + : trimmed + const separatorIndex = normalized.indexOf('=') + if (separatorIndex === -1) + return undefined + + const key = normalized.slice(0, separatorIndex).trim() + let value = normalized.slice(separatorIndex + 1).trim() + if (!key) + return undefined + + if ( + (value.startsWith('"') && value.endsWith('"')) + || (value.startsWith('\'') && value.endsWith('\'')) + ) { + value = value.slice(1, -1) + } + + return [key, value] as const +} + +export const loadE2eEnv = () => { + const envFilePath = process.env.E2E_ENV_FILE + ? path.resolve(process.env.E2E_ENV_FILE) + : path.join(e2eDir, '.env.local') + + if (!existsSync(envFilePath)) + return + + const content = readFileSync(envFilePath, 'utf8') + for (const line of content.split(/\r?\n/)) { + const entry = parseEnvLine(line) + if (!entry) + continue + + const [key, value] = entry + process.env[key] ??= value + } +} + +export const validateE2eEnv = () => createEnv({ + client: {}, + clientPrefix: 'NEXT_PUBLIC_', + emptyStringAsUndefined: true, + onValidationError: (issues) => { + const messages = issues.map((issue) => { + const path = Array.isArray(issue.path) && issue.path.length > 0 + ? issue.path.join('.') + : '(root)' + return `- ${path}: ${issue.message}` + }) + + throw new Error(`Invalid E2E env:\n${messages.join('\n')}`) + }, + runtimeEnv: { + CUCUMBER_HEADLESS: process.env.CUCUMBER_HEADLESS, + E2E_ADMIN_EMAIL: process.env.E2E_ADMIN_EMAIL, + E2E_ADMIN_NAME: process.env.E2E_ADMIN_NAME, + E2E_ADMIN_PASSWORD: process.env.E2E_ADMIN_PASSWORD, + E2E_AGENT_BACKEND_PORT: process.env.E2E_AGENT_BACKEND_PORT, + E2E_AGENT_BACKEND_URL: process.env.E2E_AGENT_BACKEND_URL, + E2E_API_URL: process.env.E2E_API_URL, + E2E_BASE_URL: process.env.E2E_BASE_URL, + E2E_BROKEN_MODEL_NAME: process.env.E2E_BROKEN_MODEL_NAME, + E2E_BROKEN_MODEL_PROVIDER: process.env.E2E_BROKEN_MODEL_PROVIDER, + E2E_BROKEN_MODEL_TYPE: process.env.E2E_BROKEN_MODEL_TYPE, + E2E_CUCUMBER_TAGS: process.env.E2E_CUCUMBER_TAGS, + E2E_EXTERNAL_RUNTIME_SEED_SPECS: process.env.E2E_EXTERNAL_RUNTIME_SEED_SPECS, + E2E_EXTERNAL_RUNTIME_TAGS: process.env.E2E_EXTERNAL_RUNTIME_TAGS, + E2E_FORCE_WEB_BUILD: process.env.E2E_FORCE_WEB_BUILD, + E2E_INIT_PASSWORD: process.env.E2E_INIT_PASSWORD, + E2E_MARKETPLACE_API_URL: process.env.E2E_MARKETPLACE_API_URL, + E2E_MARKETPLACE_PLUGIN_IDS: process.env.E2E_MARKETPLACE_PLUGIN_IDS, + E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: process.env.E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS, + E2E_MODEL_PROVIDER_CREDENTIALS_JSON: process.env.E2E_MODEL_PROVIDER_CREDENTIALS_JSON, + E2E_OAUTH_TOOL_CREDENTIAL_ID: process.env.E2E_OAUTH_TOOL_CREDENTIAL_ID, + E2E_OAUTH_TOOL_NAME: process.env.E2E_OAUTH_TOOL_NAME, + E2E_OAUTH_TOOL_PROVIDER: process.env.E2E_OAUTH_TOOL_PROVIDER, + E2E_REUSE_WEB_SERVER: process.env.E2E_REUSE_WEB_SERVER, + E2E_SLOW_MO: process.env.E2E_SLOW_MO, + E2E_SHELLCTL_AUTH_TOKEN: process.env.E2E_SHELLCTL_AUTH_TOKEN, + E2E_SHELLCTL_CONTAINER_NAME: process.env.E2E_SHELLCTL_CONTAINER_NAME, + E2E_SHELLCTL_IMAGE: process.env.E2E_SHELLCTL_IMAGE, + E2E_SHELLCTL_PORT: process.env.E2E_SHELLCTL_PORT, + E2E_SHELLCTL_URL: process.env.E2E_SHELLCTL_URL, + E2E_START_AGENT_BACKEND: process.env.E2E_START_AGENT_BACKEND, + E2E_STABLE_MODEL_NAME: process.env.E2E_STABLE_MODEL_NAME, + E2E_STABLE_MODEL_PROVIDER: process.env.E2E_STABLE_MODEL_PROVIDER, + E2E_STABLE_MODEL_TYPE: process.env.E2E_STABLE_MODEL_TYPE, + }, + server: { + CUCUMBER_HEADLESS: booleanString.optional(), + E2E_ADMIN_EMAIL: z.email().optional(), + E2E_ADMIN_NAME: z.string().min(1).optional(), + E2E_ADMIN_PASSWORD: z.string().min(1).optional(), + E2E_AGENT_BACKEND_PORT: z.coerce.number().int().positive().max(65535).optional(), + E2E_AGENT_BACKEND_URL: z.url().optional(), + E2E_API_URL: z.url().optional(), + E2E_BASE_URL: z.url().optional(), + E2E_BROKEN_MODEL_NAME: z.string().min(1).optional(), + E2E_BROKEN_MODEL_PROVIDER: z.string().min(1).optional(), + E2E_BROKEN_MODEL_TYPE: z.string().min(1).optional(), + E2E_CUCUMBER_TAGS: z.string().min(1).optional(), + E2E_EXTERNAL_RUNTIME_SEED_SPECS: z.string().min(1).optional(), + E2E_EXTERNAL_RUNTIME_TAGS: z.string().min(1).optional(), + E2E_FORCE_WEB_BUILD: z.literal('1').optional(), + E2E_INIT_PASSWORD: z.string().min(1).optional(), + E2E_MARKETPLACE_API_URL: z.url().optional(), + E2E_MARKETPLACE_PLUGIN_IDS: z.string().min(1).optional(), + E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS: z.string().min(1).optional(), + E2E_MODEL_PROVIDER_CREDENTIALS_JSON: jsonObjectString.optional(), + E2E_OAUTH_TOOL_CREDENTIAL_ID: z.string().min(1).optional(), + E2E_OAUTH_TOOL_NAME: z.string().min(1).optional(), + E2E_OAUTH_TOOL_PROVIDER: z.string().min(1).optional(), + E2E_REUSE_WEB_SERVER: booleanString.optional(), + E2E_SLOW_MO: z.coerce.number().nonnegative().optional(), + E2E_SHELLCTL_AUTH_TOKEN: z.string().min(1).optional(), + E2E_SHELLCTL_CONTAINER_NAME: z.string().min(1).optional(), + E2E_SHELLCTL_IMAGE: z.string().min(1).optional(), + E2E_SHELLCTL_PORT: z.coerce.number().int().positive().max(65535).optional(), + E2E_SHELLCTL_URL: z.url().optional(), + E2E_START_AGENT_BACKEND: booleanString.optional(), + E2E_STABLE_MODEL_NAME: z.string().min(1).optional(), + E2E_STABLE_MODEL_PROVIDER: z.string().min(1).optional(), + E2E_STABLE_MODEL_TYPE: z.string().min(1).optional(), + }, +}) diff --git a/e2e/scripts/prepare-external-runtime.ts b/e2e/scripts/prepare-external-runtime.ts new file mode 100644 index 00000000000..e1800f3de41 --- /dev/null +++ b/e2e/scripts/prepare-external-runtime.ts @@ -0,0 +1,38 @@ +import { e2eDir, isMainModule, runCommandOrThrow } from './common' +import './env-register' + +const defaultExternalRuntimeSeedSpecs = 'agent-v2:external-runtime' + +const parseSeedSpecs = (value: string) => + value + .split(',') + .map(entry => entry.trim()) + .filter(Boolean) + .map((entry) => { + const [pack, profile = 'full'] = entry.split(':').map(part => part.trim()) + if (!pack) + throw new Error(`Invalid external runtime seed spec "${entry}". Expected "pack" or "pack:profile".`) + + return { pack, profile } + }) + +const main = async () => { + const seedSpecs = parseSeedSpecs( + process.env.E2E_EXTERNAL_RUNTIME_SEED_SPECS?.trim() || defaultExternalRuntimeSeedSpecs, + ) + + for (const { pack, profile } of seedSpecs) { + await runCommandOrThrow({ + command: 'npx', + args: ['tsx', './scripts/seed.ts', '--pack', pack, '--profile', profile], + cwd: e2eDir, + }) + } +} + +if (isMainModule(import.meta.url)) { + void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) +} diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index 5913450f14b..70b0c34902a 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.ts @@ -6,6 +6,7 @@ import { startWebServer, stopWebServer } from '../support/web-server' import { apiURL, baseURL, reuseExistingWebServer } from '../test-env' import { e2eDir, isMainModule, runCommand } from './common' import { resetState, startMiddleware, stopMiddleware } from './setup' +import './env-register' type RunOptions = { forwardArgs: string[] @@ -43,6 +44,20 @@ const parseArgs = (argv: string[]): RunOptions => { const hasCustomTags = (forwardArgs: string[]) => forwardArgs.some(arg => arg === '--tags' || arg.startsWith('--tags=')) +const fullNonExternalTags = 'not @skip and not @preview and not @external-model and not @external-tool' + +const isTruthyEnv = (value: string | undefined) => value === '1' || value === 'true' + +const shouldStartAgentBackend = () => { + if (isTruthyEnv(process.env.E2E_START_AGENT_BACKEND)) + return true + + if (process.env.E2E_AGENT_BACKEND_URL || process.env.AGENT_BACKEND_BASE_URL) + return false + + return false +} + const readLogTail = async (logFilePath: string) => { const content = await readFile(logFilePath, 'utf8').catch(() => '') @@ -85,6 +100,7 @@ const main = async () => { const { forwardArgs, full, headed } = parseArgs(process.argv.slice(2)) const startMiddlewareForRun = full const resetStateForRun = full + const startAgentBackendForRun = shouldStartAgentBackend() if (resetStateForRun) await resetState() @@ -98,10 +114,38 @@ const main = async () => { await rm(cucumberReportDir, { force: true, recursive: true }) await mkdir(logDir, { recursive: true }) + const shellctlProcess = startAgentBackendForRun + ? await startLoggedProcess({ + command: 'npx', + args: ['tsx', './scripts/setup.ts', 'shellctl-sandbox'], + cwd: e2eDir, + label: 'shellctl sandbox', + logFilePath: path.join(logDir, 'cucumber-shellctl-sandbox.log'), + }) + : undefined + + const difyAgentProcess = startAgentBackendForRun + ? await startLoggedProcess({ + command: 'npx', + args: ['tsx', './scripts/setup.ts', 'agent-backend'], + cwd: e2eDir, + env: { + E2E_START_AGENT_BACKEND: '1', + }, + label: 'agent backend', + logFilePath: path.join(logDir, 'cucumber-agent-backend.log'), + }) + : undefined + const apiProcess = await startLoggedProcess({ command: 'npx', args: ['tsx', './scripts/setup.ts', 'api'], cwd: e2eDir, + env: startAgentBackendForRun + ? { + E2E_START_AGENT_BACKEND: '1', + } + : undefined, label: 'api server', logFilePath: path.join(logDir, 'cucumber-api.log'), }) @@ -121,6 +165,8 @@ const main = async () => { await stopWebServer() await stopManagedProcess(celeryProcess) await stopManagedProcess(apiProcess) + await stopManagedProcess(difyAgentProcess) + await stopManagedProcess(shellctlProcess) if (startMiddlewareForRun) { try { @@ -146,6 +192,50 @@ const main = async () => { process.once('SIGTERM', onTerminate) try { + if (shellctlProcess) { + let waitingForShellctl = true + try { + const shellctlPort = process.env.E2E_SHELLCTL_PORT || '5004' + await Promise.race([ + waitForUrl(`http://127.0.0.1:${shellctlPort}/openapi.json`, 180_000, 1_000), + waitForUnexpectedProcessExit(shellctlProcess, () => !waitingForShellctl), + ]) + } + catch (error) { + if (error instanceof Error && error.message.includes('exited before becoming ready')) + throw error + + throw new Error( + `Shellctl sandbox did not become ready. See ${shellctlProcess.logFilePath}.`, + ) + } + finally { + waitingForShellctl = false + } + } + + if (difyAgentProcess) { + let waitingForAgentBackend = true + try { + const agentBackendPort = process.env.E2E_AGENT_BACKEND_PORT || '5050' + await Promise.race([ + waitForUrl(`http://127.0.0.1:${agentBackendPort}/openapi.json`, 180_000, 1_000), + waitForUnexpectedProcessExit(difyAgentProcess, () => !waitingForAgentBackend), + ]) + } + catch (error) { + if (error instanceof Error && error.message.includes('exited before becoming ready')) + throw error + + throw new Error( + `Agent backend did not become ready. See ${difyAgentProcess.logFilePath}.`, + ) + } + finally { + waitingForAgentBackend = false + } + } + let waitingForApi = true try { await Promise.race([ @@ -179,7 +269,7 @@ const main = async () => { } if (startMiddlewareForRun && !hasCustomTags(forwardArgs)) - cucumberEnv.E2E_CUCUMBER_TAGS = 'not @skip and not @preview' + cucumberEnv.E2E_CUCUMBER_TAGS = fullNonExternalTags const result = await runCommand({ command: 'npx', diff --git a/e2e/scripts/run-external-runtime.ts b/e2e/scripts/run-external-runtime.ts new file mode 100644 index 00000000000..d5ea4a5ecb4 --- /dev/null +++ b/e2e/scripts/run-external-runtime.ts @@ -0,0 +1,17 @@ +import { e2eDir, isMainModule, runForegroundProcess } from './common' +import './env-register' + +const defaultExternalRuntimeTags = '(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview' + +const main = async () => { + const tags = process.env.E2E_EXTERNAL_RUNTIME_TAGS?.trim() || defaultExternalRuntimeTags + + await runForegroundProcess({ + command: 'npx', + args: ['tsx', './scripts/run-cucumber.ts', '--', '--tags', tags], + cwd: e2eDir, + }) +} + +if (isMainModule(import.meta.url)) + void main() diff --git a/e2e/scripts/seed.ts b/e2e/scripts/seed.ts new file mode 100644 index 00000000000..82d5dddf22c --- /dev/null +++ b/e2e/scripts/seed.ts @@ -0,0 +1,167 @@ +import type { ManagedProcess } from '../support/process' +import { mkdir } from 'node:fs/promises' +import path from 'node:path' +import { chromium } from '@playwright/test' +import { createAgentV2SeedTasks } from '../features/agent-v2/support/seed' +import { ensureAuthenticatedState } from '../fixtures/auth' +import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process' +import { runSeedTasks, writeSeedReport } from '../support/seed' +import { startWebServer, stopWebServer } from '../support/web-server' +import { apiURL, baseURL, reuseExistingWebServer } from '../test-env' +import { e2eDir, isMainModule } from './common' +import './env-register' + +type SeedOptions = { + allowBlocked: boolean + dryRun: boolean + pack: string + profile: string +} + +const parseArgs = (argv: string[]): SeedOptions => { + const options: SeedOptions = { + allowBlocked: false, + dryRun: false, + pack: 'agent-v2', + profile: 'full', + } + + for (const [index, arg] of argv.entries()) { + if (arg === '--pack') { + options.pack = argv[index + 1] || options.pack + continue + } + if (arg.startsWith('--pack=')) { + options.pack = arg.slice('--pack='.length) + continue + } + if (arg === '--dry-run') + options.dryRun = true + if (arg === '--allow-blocked') + options.allowBlocked = true + if (arg === '--profile') { + options.profile = argv[index + 1] || options.profile + continue + } + if (arg.startsWith('--profile=')) { + options.profile = arg.slice('--profile='.length) + continue + } + } + + return options +} + +const getTasks = (pack: string, profile: string) => { + if (pack === 'agent-v2') + return createAgentV2SeedTasks(profile) + + throw new Error(`Unknown seed pack "${pack}".`) +} + +const ensureAuth = async () => { + const browser = await chromium.launch({ headless: true }) + try { + await ensureAuthenticatedState(browser, baseURL) + } + finally { + await browser.close() + } +} + +const startApiProcess = async (logDir: string) => { + try { + await waitForUrl(`${apiURL}/health`, 1_000, 250, 1_000) + return undefined + } + catch { + // Start a local API process below. + } + + const apiProcess = await startLoggedProcess({ + command: 'npx', + args: ['tsx', './scripts/setup.ts', 'api'], + cwd: e2eDir, + label: 'api server', + logFilePath: path.join(logDir, 'seed-api.log'), + }) + + try { + await waitForUrl(`${apiURL}/health`, 180_000, 1_000) + return apiProcess + } + catch (error) { + await stopManagedProcess(apiProcess) + throw error + } +} + +const startCeleryProcess = async (logDir: string) => + startLoggedProcess({ + command: 'npx', + args: [ + 'tsx', + './scripts/setup.ts', + 'celery', + '--queues', + 'dataset,priority_dataset,workflow_based_app_execution', + ], + cwd: e2eDir, + label: 'celery worker', + logFilePath: path.join(logDir, 'seed-celery.log'), + }) + +const main = async () => { + const options = parseArgs(process.argv.slice(2)) + const logDir = path.join(e2eDir, '.logs') + let apiProcess: ManagedProcess | undefined + let celeryProcess: ManagedProcess | undefined + + await mkdir(logDir, { recursive: true }) + + try { + apiProcess = await startApiProcess(logDir) + celeryProcess = await startCeleryProcess(logDir) + await startWebServer({ + baseURL, + command: 'npx', + args: ['tsx', './scripts/setup.ts', 'web'], + cwd: e2eDir, + logFilePath: path.join(logDir, 'seed-web.log'), + reuseExistingServer: reuseExistingWebServer, + timeoutMs: 300_000, + }) + + console.warn(`[seed] bootstrapping auth state against ${baseURL}`) + await ensureAuth() + + const results = await runSeedTasks(getTasks(options.pack, options.profile), { + dryRun: options.dryRun, + resources: new Map(), + }) + const reportName = options.profile === 'full' + ? options.pack + : `${options.pack}-${options.profile}` + const reportPath = await writeSeedReport(reportName, results) + const blockedCount = results.filter(result => result.status === 'blocked').length + + console.warn(`[seed] report ${reportPath}`) + if (blockedCount > 0 && !options.allowBlocked) { + throw new Error( + `${blockedCount} seed task${blockedCount === 1 ? '' : 's'} blocked. Re-run with --allow-blocked only when partial readiness is intentional.`, + ) + } + } + finally { + await stopWebServer() + await stopManagedProcess(celeryProcess) + await stopManagedProcess(apiProcess) + } +} + +if (isMainModule(import.meta.url)) { + void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + }) +} diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index b1f623f923b..0654772142f 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -5,6 +5,7 @@ import { waitForUrl } from '../support/process' import { apiDir, apiEnvExampleFile, + difyAgentDir, dockerDir, e2eDir, e2eWebEnvOverrides, @@ -30,6 +31,15 @@ const buildIdPath = path.join(webDir, '.next', 'BUILD_ID') const webBuildStampPath = path.join(webDir, '.next', 'e2e-web-build.sha256') const apiHost = '127.0.0.1' const apiPort = 5001 +const agentBackendHost = '127.0.0.1' +const agentBackendPort = Number(process.env.E2E_AGENT_BACKEND_PORT || 5050) +const shellctlHost = '127.0.0.1' +const shellctlPort = Number(process.env.E2E_SHELLCTL_PORT || 5004) +const shellctlContainerName = process.env.E2E_SHELLCTL_CONTAINER_NAME || 'dify-agent-e2e-shellctl' +const shellctlImage = process.env.E2E_SHELLCTL_IMAGE || 'dify-agent-local-sandbox:e2e' +const shellctlUrl = `http://${shellctlHost}:${shellctlPort}` +const defaultPluginDaemonKey = 'lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi' +const defaultInnerApiKeyForPlugin = 'QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1' const middlewareDataPaths = [ path.join(dockerDir, 'volumes', 'db', 'data'), @@ -56,15 +66,68 @@ const composeArgs = [ 'weaviate', ] -const getApiEnvironment = async () => { +const getApiEnvironment = async (): Promise> => { const envFromExample = await readSimpleDotenv(apiEnvExampleFile) + const agentBackendBaseUrl = getAgentBackendBaseUrl() return { ...envFromExample, + ...(agentBackendBaseUrl ? { AGENT_BACKEND_BASE_URL: agentBackendBaseUrl } : {}), FLASK_APP: 'app.py', } } +function getAgentBackendBaseUrl() { + const explicitApiUrl = process.env.AGENT_BACKEND_BASE_URL?.trim() + if (explicitApiUrl) + return explicitApiUrl + + const explicitE2EUrl = process.env.E2E_AGENT_BACKEND_URL?.trim() + if (explicitE2EUrl) + return explicitE2EUrl.replace(/\/$/, '') + + if (process.env.E2E_START_AGENT_BACKEND === '1' || process.env.E2E_START_AGENT_BACKEND === 'true') + return `http://${agentBackendHost}:${agentBackendPort}` + + return undefined +} + +const getAgentBackendEnvironment = async () => { + const apiEnv = await getApiEnvironment() + const redisPassword = process.env.REDIS_PASSWORD || apiEnv.REDIS_PASSWORD || 'difyai123456' + + return { + DIFY_AGENT_INNER_API_KEY: + process.env.DIFY_AGENT_INNER_API_KEY + || process.env.INNER_API_KEY_FOR_PLUGIN + || process.env.PLUGIN_DIFY_INNER_API_KEY + || apiEnv.INNER_API_KEY_FOR_PLUGIN + || defaultInnerApiKeyForPlugin, + DIFY_AGENT_INNER_API_URL: process.env.DIFY_AGENT_INNER_API_URL || `http://${apiHost}:${apiPort}`, + DIFY_AGENT_PLUGIN_DAEMON_API_KEY: + process.env.DIFY_AGENT_PLUGIN_DAEMON_API_KEY + || process.env.PLUGIN_DAEMON_KEY + || apiEnv.PLUGIN_DAEMON_KEY + || defaultPluginDaemonKey, + DIFY_AGENT_PLUGIN_DAEMON_URL: + process.env.DIFY_AGENT_PLUGIN_DAEMON_URL + || process.env.PLUGIN_DAEMON_URL + || 'http://127.0.0.1:5002', + DIFY_AGENT_REDIS_PREFIX: process.env.DIFY_AGENT_REDIS_PREFIX || 'dify-agent-e2e', + DIFY_AGENT_REDIS_URL: + process.env.DIFY_AGENT_REDIS_URL + || `redis://:${redisPassword}@127.0.0.1:6379/0`, + DIFY_AGENT_SHELLCTL_AUTH_TOKEN: + process.env.DIFY_AGENT_SHELLCTL_AUTH_TOKEN + || process.env.E2E_SHELLCTL_AUTH_TOKEN + || '', + DIFY_AGENT_SHELLCTL_ENTRYPOINT: + process.env.DIFY_AGENT_SHELLCTL_ENTRYPOINT + || process.env.E2E_SHELLCTL_URL + || shellctlUrl, + } +} + const getServiceContainerId = async (service: string) => { const result = await runCommandOrThrow({ command: 'docker', @@ -272,7 +335,103 @@ export const startApi = async () => { }) } -export const startCelery = async () => { +export const startAgentBackend = async () => { + if (await isTcpPortReachable(agentBackendHost, agentBackendPort)) { + const listenerDescription = await getTcpPortListenerDescription(agentBackendPort) + const listenerMessage = listenerDescription + ? `\n\nPort listener:\n${listenerDescription}` + : '' + + throw new Error( + `Cannot start the E2E Agent backend because ${agentBackendHost}:${agentBackendPort} is already in use.${listenerMessage}`, + ) + } + + await runForegroundProcess({ + command: 'uv', + args: [ + 'run', + '--project', + '.', + '--extra', + 'server', + 'uvicorn', + 'dify_agent.server.app:app', + '--host', + agentBackendHost, + '--port', + String(agentBackendPort), + ], + cwd: difyAgentDir, + env: await getAgentBackendEnvironment(), + }) +} + +const ensureShellctlSandboxImage = async () => { + const inspectResult = await runCommand({ + command: 'docker', + args: ['image', 'inspect', shellctlImage], + cwd: rootDir, + stdio: 'pipe', + }) + + if (inspectResult.exitCode === 0 && process.env.E2E_FORCE_SHELLCTL_BUILD !== '1') + return + + await runCommandOrThrow({ + command: 'docker', + args: [ + 'build', + '-f', + path.join(difyAgentDir, 'docker', 'local-sandbox', 'Dockerfile'), + '-t', + shellctlImage, + '.', + ], + cwd: difyAgentDir, + }) +} + +export const startShellctlSandbox = async () => { + if (await isTcpPortReachable(shellctlHost, shellctlPort)) { + const listenerDescription = await getTcpPortListenerDescription(shellctlPort) + const listenerMessage = listenerDescription + ? `\n\nPort listener:\n${listenerDescription}` + : '' + + throw new Error( + `Cannot start the E2E shellctl sandbox because ${shellctlHost}:${shellctlPort} is already in use.${listenerMessage}`, + ) + } + + await runCommand({ + command: 'docker', + args: ['rm', '-f', shellctlContainerName], + cwd: rootDir, + stdio: 'pipe', + }) + + await ensureShellctlSandboxImage() + + await runForegroundProcess({ + command: 'docker', + args: [ + 'run', + '--rm', + '--name', + shellctlContainerName, + '-p', + `${shellctlHost}:${shellctlPort}:5004`, + ...(process.env.E2E_SHELLCTL_AUTH_TOKEN + ? ['-e', `SHELLCTL_AUTH_TOKEN=${process.env.E2E_SHELLCTL_AUTH_TOKEN}`] + : []), + shellctlImage, + ], + cwd: rootDir, + }) +} + +export const startCelery = async ({ queues = 'workflow_based_app_execution' } = {}) => { const env = await getApiEnvironment() await runForegroundProcess({ @@ -291,7 +450,7 @@ export const startCelery = async () => { '--loglevel', 'INFO', '-Q', - 'workflow_based_app_execution', + queues, ], cwd: apiDir, env, @@ -404,18 +563,23 @@ export const startMiddleware = async () => { } const printUsage = () => { - console.log('Usage: tsx ./scripts/setup.ts ') + console.log('Usage: tsx ./scripts/setup.ts ') } const main = async () => { const command = process.argv[2] + const queuesIndex = process.argv.indexOf('--queues') + const queues = queuesIndex === -1 ? undefined : process.argv[queuesIndex + 1] switch (command) { + case 'agent-backend': + await startAgentBackend() + return case 'api': await startApi() return case 'celery': - await startCelery() + await startCelery({ queues }) return case 'middleware-down': await stopMiddleware() @@ -426,6 +590,9 @@ const main = async () => { case 'reset': await resetState() return + case 'shellctl-sandbox': + await startShellctlSandbox() + return case 'web': await startWeb() return diff --git a/e2e/support/marketplace-plugins.ts b/e2e/support/marketplace-plugins.ts new file mode 100644 index 00000000000..cd12778b06b --- /dev/null +++ b/e2e/support/marketplace-plugins.ts @@ -0,0 +1,333 @@ +import type { SeedContext, SeedResult } from './seed' +import { Buffer } from 'node:buffer' +import { createApiContext, expectApiResponseOK } from './api' +import { sleep } from './process' +import { + blocked, + created, + skipped, + verified, +} from './seed' + +type LatestPlugin = { + unique_identifier?: string + version?: string +} + +type PluginInstallation = { + plugin_id: string + plugin_unique_identifier: string +} + +type PluginInstallTask = { + id?: string + plugins?: Array<{ + message?: string + plugin_id?: string + plugin_unique_identifier?: string + status?: string + }> + status?: string +} + +type PluginInstallStartResponse = { + all_installed?: boolean + task?: PluginInstallTask | null + task_id?: string +} + +type MarketplacePluginBootstrapConfig = { + defaultPluginIds: string[] + pluginIdsEnv: string + pluginUniqueIdentifiersEnv: string + title: string +} + +const pendingTaskStatuses = new Set(['pending', 'running']) +const terminalSuccessTaskStatus = 'success' +const terminalFailedTaskStatus = 'failed' +const defaultMarketplaceApiUrl = 'https://marketplace.dify.ai' + +const parseListEnv = (envName: string) => + process.env[envName] + ?.split(/[\n,]/) + .map(item => item.trim()) + .filter(Boolean) ?? [] + +const unique = (values: string[]) => Array.from(new Set(values)) + +const getPluginId = (pluginUniqueIdentifier: string) => + pluginUniqueIdentifier.split(':')[0]?.trim() || pluginUniqueIdentifier.trim() + +const findPlaceholderPluginIdentifier = (pluginUniqueIdentifiers: string[]) => + pluginUniqueIdentifiers.find(identifier => identifier.includes('replace-with-')) + +const withoutPlaceholderPluginIdentifiers = (pluginUniqueIdentifiers: string[]) => + pluginUniqueIdentifiers.filter(identifier => !identifier.includes('replace-with-')) + +const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { + if (pluginIds.length === 0) + return { identifiers: [] as string[], missing: [] as string[] } + + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/workspaces/current/plugin/list/latest-versions', { + data: { plugin_ids: pluginIds }, + }) + await expectApiResponseOK(response, 'Resolve latest marketplace plugin versions') + const body = (await response.json()) as { versions?: Record } + const identifiers: string[] = [] + const missing: string[] = [] + + for (const pluginId of pluginIds) { + const latest = body.versions?.[pluginId] + if (latest?.unique_identifier) + identifiers.push(latest.unique_identifier) + else + missing.push(pluginId) + } + + return { identifiers, missing } + } + finally { + await ctx.dispose() + } +} + +const listInstalledPlugins = async (pluginIds: string[]) => { + if (pluginIds.length === 0) + return [] as PluginInstallation[] + + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/workspaces/current/plugin/list/installations/ids', { + data: { plugin_ids: pluginIds }, + }) + await expectApiResponseOK(response, 'List installed marketplace plugins') + const body = (await response.json()) as { plugins?: PluginInstallation[] } + return body.plugins ?? [] + } + finally { + await ctx.dispose() + } +} + +const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => { + const deadline = Date.now() + timeoutMs + let lastTask: PluginInstallTask | undefined + + while (Date.now() < deadline) { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/workspaces/current/plugin/tasks/${taskId}`) + await expectApiResponseOK(response, `Fetch marketplace plugin install task ${taskId}`) + const body = (await response.json()) as { task?: PluginInstallTask } + lastTask = body.task + } + finally { + await ctx.dispose() + } + + if (lastTask?.status === terminalSuccessTaskStatus) + return { ok: true as const, task: lastTask } + + if (lastTask?.status === terminalFailedTaskStatus) { + const details = lastTask.plugins + ?.filter(plugin => plugin.status === terminalFailedTaskStatus) + .map(plugin => `${plugin.plugin_id ?? plugin.plugin_unique_identifier ?? 'unknown'}: ${plugin.message ?? 'failed'}`) + .join('; ') + return { ok: false as const, reason: details || 'Plugin install task failed.' } + } + + if (!lastTask?.status || pendingTaskStatuses.has(lastTask.status)) { + await sleep(2_000) + continue + } + + return { ok: false as const, reason: `Plugin install task ended with status ${lastTask.status}.` } + } + + return { ok: false as const, reason: `Plugin install task did not finish within ${timeoutMs}ms.` } +} + +const installMarketplacePlugins = async (pluginUniqueIdentifiers: string[]) => { + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/workspaces/current/plugin/install/marketplace', { + data: { plugin_unique_identifiers: pluginUniqueIdentifiers }, + }) + await expectApiResponseOK(response, 'Install marketplace plugins') + return (await response.json()) as PluginInstallStartResponse + } + finally { + await ctx.dispose() + } +} + +const getMarketplaceDownloadUrl = (pluginUniqueIdentifier: string) => { + const url = new URL('/api/v1/plugins/download', process.env.E2E_MARKETPLACE_API_URL || defaultMarketplaceApiUrl) + url.searchParams.set('unique_identifier', pluginUniqueIdentifier) + return url.toString() +} + +const downloadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) => { + const response = await fetch(getMarketplaceDownloadUrl(pluginUniqueIdentifier)) + if (!response.ok) { + throw new Error( + `Download marketplace package for ${getPluginId(pluginUniqueIdentifier)} failed with ${response.status} ${response.statusText}.`, + ) + } + + return Buffer.from(await response.arrayBuffer()) +} + +const uploadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) => { + const pkg = await downloadMarketplacePluginPackage(pluginUniqueIdentifier) + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/workspaces/current/plugin/upload/pkg', { + multipart: { + pkg: { + buffer: pkg, + mimeType: 'application/octet-stream', + name: `${getPluginId(pluginUniqueIdentifier).replaceAll('/', '-')}.difypkg`, + }, + }, + }) + await expectApiResponseOK(response, `Upload marketplace package ${getPluginId(pluginUniqueIdentifier)}`) + const body = (await response.json()) as { unique_identifier?: string } + if (!body.unique_identifier) + throw new Error(`Upload marketplace package ${getPluginId(pluginUniqueIdentifier)} did not return a unique identifier.`) + + return body.unique_identifier + } + finally { + await ctx.dispose() + } +} + +const installLocalPluginPackages = async (pluginUniqueIdentifiers: string[]) => { + const ctx = await createApiContext() + try { + const response = await ctx.post('/console/api/workspaces/current/plugin/install/pkg', { + data: { plugin_unique_identifiers: pluginUniqueIdentifiers }, + }) + await expectApiResponseOK(response, 'Install uploaded plugin packages') + return (await response.json()) as PluginInstallStartResponse + } + finally { + await ctx.dispose() + } +} + +const shouldFallbackToLocalPackageInstall = (error: string) => + error.includes('/plugins/download') || error.includes('Reached maximum retries') + +const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: string[]) => { + try { + return await installMarketplacePlugins(pluginUniqueIdentifiers) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!shouldFallbackToLocalPackageInstall(message)) + throw error + + console.warn('[seed] marketplace install download failed in API process; falling back to local package upload.') + const uploadedPluginUniqueIdentifiers: string[] = [] + for (const pluginUniqueIdentifier of pluginUniqueIdentifiers) + uploadedPluginUniqueIdentifiers.push(await uploadMarketplacePluginPackage(pluginUniqueIdentifier)) + + return await installLocalPluginPackages(uploadedPluginUniqueIdentifiers) + } +} + +export const bootstrapMarketplacePlugins = async ( + context: SeedContext, + config: MarketplacePluginBootstrapConfig, +): Promise => { + const requestedPluginIds = parseListEnv(config.pluginIdsEnv) + const rawExactPluginUniqueIdentifiers = unique(parseListEnv(config.pluginUniqueIdentifiersEnv)) + const exactPluginUniqueIdentifiers = withoutPlaceholderPluginIdentifiers(rawExactPluginUniqueIdentifiers) + const placeholderPluginIdentifier = findPlaceholderPluginIdentifier(rawExactPluginUniqueIdentifiers) + if (placeholderPluginIdentifier) { + console.warn(`[seed] ignoring example marketplace package placeholder for ${getPluginId(placeholderPluginIdentifier)}.`) + } + + const pluginIds = unique( + exactPluginUniqueIdentifiers.length > 0 + ? [] + : requestedPluginIds.length > 0 + ? requestedPluginIds + : config.defaultPluginIds, + ) + + if (pluginIds.length > 0) { + const installedPlugins = await listInstalledPlugins(pluginIds) + const installedPluginIds = new Set(installedPlugins.map(plugin => plugin.plugin_id)) + if (pluginIds.every(pluginId => installedPluginIds.has(pluginId))) { + return verified(config.title, { + id: pluginIds.join(','), + kind: 'marketplace-plugins', + name: config.title, + }) + } + } + + const resolved = await resolveLatestPluginIdentifiers(pluginIds) + + if (resolved.missing.length > 0) { + return blocked( + config.title, + `Marketplace metadata was not found for plugin ids: ${resolved.missing.join(', ')}. Set ${config.pluginUniqueIdentifiersEnv} to exact package identifiers to bypass latest-version lookup.`, + ) + } + + const requiredPluginUniqueIdentifiers = unique([ + ...resolved.identifiers, + ...exactPluginUniqueIdentifiers, + ]) + const requiredPluginIds = unique(requiredPluginUniqueIdentifiers.map(getPluginId)) + + if (requiredPluginUniqueIdentifiers.length === 0) + return skipped(config.title, 'No marketplace plugins were requested.') + + const installedPlugins = await listInstalledPlugins(requiredPluginIds) + const installedPluginIds = new Set(installedPlugins.map(plugin => plugin.plugin_id)) + const missingPluginUniqueIdentifiers = requiredPluginUniqueIdentifiers.filter( + identifier => !installedPluginIds.has(getPluginId(identifier)), + ) + const resource = { + id: requiredPluginIds.join(','), + kind: 'marketplace-plugins', + name: config.title, + } + + if (missingPluginUniqueIdentifiers.length === 0) + return verified(config.title, resource) + + if (context.dryRun) { + return skipped( + config.title, + `Would install marketplace plugins: ${missingPluginUniqueIdentifiers.map(getPluginId).join(', ')}.`, + ) + } + + const startedTask = await installMarketplacePluginsWithFallback(missingPluginUniqueIdentifiers).catch((error) => { + return { error: error instanceof Error ? error.message : String(error) } + }) + if ('error' in startedTask) + return blocked(config.title, startedTask.error) + + if (startedTask.all_installed) + return verified(config.title, resource) + + const taskId = startedTask.task_id || startedTask.task?.id + if (!taskId) + return blocked(config.title, 'Marketplace plugin install did not return a task id.') + + const taskResult = await waitForPluginInstallTask(taskId) + if (!taskResult.ok) + return blocked(config.title, taskResult.reason) + + return created(config.title, resource) +} diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts new file mode 100644 index 00000000000..1e89cbcee55 --- /dev/null +++ b/e2e/support/seed.ts @@ -0,0 +1,93 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { e2eDir } from '../scripts/common' + +export type SeedStatus = 'blocked' | 'created' | 'skipped' | 'updated' | 'verified' + +export type SeedResource = { + id?: string + kind: string + name: string +} + +export type SeedResult = { + reason?: string + resource?: SeedResource + status: SeedStatus + title: string +} + +export type SeedContext = { + dryRun: boolean + resources: Map +} + +export type SeedTask = { + id: string + run: (context: SeedContext) => Promise + title: string +} + +const reportDir = path.join(e2eDir, 'seed-report') + +export const created = (title: string, resource?: SeedResource): SeedResult => ({ + resource, + status: 'created', + title, +}) + +export const updated = (title: string, resource?: SeedResource): SeedResult => ({ + resource, + status: 'updated', + title, +}) + +export const verified = (title: string, resource?: SeedResource): SeedResult => ({ + resource, + status: 'verified', + title, +}) + +export const skipped = (title: string, reason: string): SeedResult => ({ + reason, + status: 'skipped', + title, +}) + +export const blocked = (title: string, reason: string): SeedResult => ({ + reason, + status: 'blocked', + title, +}) + +export async function runSeedTasks(tasks: SeedTask[], context: SeedContext) { + const results: SeedResult[] = [] + + for (const task of tasks) { + const result = await task.run(context) + results.push(result) + if (result.resource) + context.resources.set(task.id, result.resource) + + const suffix = result.reason ? `: ${result.reason}` : '' + console.warn(`[seed] ${result.status.padEnd(8)} ${result.title}${suffix}`) + } + + return results +} + +export async function writeSeedReport(pack: string, results: SeedResult[]) { + await mkdir(reportDir, { recursive: true }) + const reportPath = path.join(reportDir, `${pack}.json`) + await writeFile( + reportPath, + `${JSON.stringify({ + generated_at: new Date().toISOString(), + pack, + results, + }, null, 2)}\n`, + 'utf8', + ) + + return reportPath +} diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ef487d7e69e..d08aba6fad7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1170,12 +1170,6 @@ } }, "web/app/components/base/chat/chat/answer/workflow-process.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - }, "react/set-state-in-effect": { "count": 1 } diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index c7a35f003db..5a28df41715 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -18,7 +18,7 @@ export type AgentAppCreatePayload = { icon_background?: string | null icon_type?: IconType | null name: string - role: string + role?: string | null } export type AgentAppDetailWithSite = { @@ -74,7 +74,7 @@ export type AgentAppUpdatePayload = { icon_type?: IconType | null max_active_requests?: number | null name: string - role: string + role?: string | null use_icon_as_answer_icon?: boolean | null } @@ -846,7 +846,7 @@ export type AgentSensitiveWordAvoidanceFeatureConfig = { export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { enabled?: boolean - model?: AgentSoulModelConfig | null + model?: AgentSuggestedQuestionsAfterAnswerModelConfig | null prompt?: string | null [key: string]: unknown } @@ -1327,6 +1327,16 @@ export type AgentModerationProviderConfig = { [key: string]: unknown } +export type AgentSuggestedQuestionsAfterAnswerModelConfig = { + completion_params?: { + [key: string]: unknown + } | null + mode?: string | null + name: string + provider: string + [key: string]: unknown +} + export type SimpleAccount = { email: string id: string diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 52f46a4ae1f..0ae9fd0dae6 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -236,7 +236,7 @@ export const zAgentAppCreatePayload = z.object({ icon_background: z.string().nullish(), icon_type: zIconType.nullish(), name: z.string().min(1), - role: z.string().min(1).max(255), + role: z.string().max(255).nullish(), }) /** @@ -249,7 +249,7 @@ export const zAgentAppUpdatePayload = z.object({ icon_type: zIconType.nullish(), max_active_requests: z.int().nullish(), name: z.string().min(1), - role: z.string().min(1).max(255), + role: z.string().max(255).nullish(), use_icon_as_answer_icon: z.boolean().nullish(), }) @@ -1327,6 +1327,27 @@ export const zAgentComposerDifyToolCandidateResponse = z.object({ tools_count: z.int().nullish(), }) +/** + * AgentSuggestedQuestionsAfterAnswerModelConfig + * + * Legacy Chat App model config used only for follow-up question generation. + */ +export const zAgentSuggestedQuestionsAfterAnswerModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).nullish(), + mode: z.string().max(64).nullish(), + name: z.string().min(1).max(255), + provider: z.string().min(1).max(255), +}) + +/** + * AgentSuggestedQuestionsAfterAnswerFeatureConfig + */ +export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ + enabled: z.boolean().optional().default(false), + model: zAgentSuggestedQuestionsAfterAnswerModelConfig.nullish(), + prompt: z.string().nullish(), +}) + /** * SimpleAccount */ @@ -1760,7 +1781,7 @@ export const zAgentSecretRefConfig = z.object({ provider_credential_id: z.string().max(255).nullish(), ref: z.string().max(255).nullish(), type: z.string().max(64).nullish(), - value: z.string().max(255).nullish(), + value: z.string().nullish(), variable: z.string().max(255).nullish(), }) @@ -1887,6 +1908,37 @@ export const zAgentSensitiveWordAvoidanceFeatureConfig = z.object({ type: z.string().nullish(), }) +/** + * AgentAppFeaturesPayload + * + * Presentation features configurable on an Agent App. + * + * All fields are optional; an omitted field is reset to its disabled/empty + * default (the config form sends the full desired feature state on save). + */ +export const zAgentAppFeaturesPayload = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + +/** + * AgentSoulAppFeaturesConfig + */ +export const zAgentSoulAppFeaturesConfig = z.object({ + opening_statement: z.string().nullish(), + retriever_resource: zAgentFeatureToggleConfig.nullish(), + sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), + speech_to_text: zAgentFeatureToggleConfig.nullish(), + suggested_questions: z.array(z.string()).nullish(), + suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), + text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), +}) + export const zJsonValue2 = z.unknown() /** @@ -1943,46 +1995,6 @@ export const zAgentSoulModelConfig = z.object({ plugin_id: z.string().min(1).max(255), }) -/** - * AgentSuggestedQuestionsAfterAnswerFeatureConfig - */ -export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ - enabled: z.boolean().optional().default(false), - model: zAgentSoulModelConfig.nullish(), - prompt: z.string().nullish(), -}) - -/** - * AgentAppFeaturesPayload - * - * Presentation features configurable on an Agent App. - * - * All fields are optional; an omitted field is reset to its disabled/empty - * default (the config form sends the full desired feature state on save). - */ -export const zAgentAppFeaturesPayload = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.nullish(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), - speech_to_text: zAgentFeatureToggleConfig.nullish(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), - text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), -}) - -/** - * AgentSoulAppFeaturesConfig - */ -export const zAgentSoulAppFeaturesConfig = z.object({ - opening_statement: z.string().nullish(), - retriever_resource: zAgentFeatureToggleConfig.nullish(), - sensitive_word_avoidance: zAgentSensitiveWordAvoidanceFeatureConfig.nullish(), - speech_to_text: zAgentFeatureToggleConfig.nullish(), - suggested_questions: z.array(z.string()).nullish(), - suggested_questions_after_answer: zAgentSuggestedQuestionsAfterAnswerFeatureConfig.nullish(), - text_to_speech: zAgentTextToSpeechFeatureConfig.nullish(), -}) - /** * DeclaredOutputCheckConfig * diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index f880dbd9004..6c8ab2f2cf2 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2635,7 +2635,7 @@ export type AgentSensitiveWordAvoidanceFeatureConfig = { export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = { enabled?: boolean - model?: AgentSoulModelConfig | null + model?: AgentSuggestedQuestionsAfterAnswerModelConfig | null prompt?: string | null [key: string]: unknown } @@ -2875,6 +2875,16 @@ export type AgentModerationProviderConfig = { [key: string]: unknown } +export type AgentSuggestedQuestionsAfterAnswerModelConfig = { + completion_params?: { + [key: string]: unknown + } | null + mode?: string | null + name: string + provider: string + [key: string]: unknown +} + export type AgentKnowledgeDatasetConfig = { description?: string | null id?: string | null diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 74185ad0dfa..dfa767fd22e 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -3339,7 +3339,7 @@ export const zAgentSecretRefConfig = z.object({ provider_credential_id: z.string().max(255).nullish(), ref: z.string().max(255).nullish(), type: z.string().max(64).nullish(), - value: z.string().max(255).nullish(), + value: z.string().nullish(), variable: z.string().max(255).nullish(), }) @@ -3457,6 +3457,27 @@ export const zUserActionConfig = z.object({ title: z.string().max(100), }) +/** + * AgentSuggestedQuestionsAfterAnswerModelConfig + * + * Legacy Chat App model config used only for follow-up question generation. + */ +export const zAgentSuggestedQuestionsAfterAnswerModelConfig = z.object({ + completion_params: z.record(z.string(), z.unknown()).nullish(), + mode: z.string().max(64).nullish(), + name: z.string().min(1).max(255), + provider: z.string().min(1).max(255), +}) + +/** + * AgentSuggestedQuestionsAfterAnswerFeatureConfig + */ +export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ + enabled: z.boolean().optional().default(false), + model: zAgentSuggestedQuestionsAfterAnswerModelConfig.nullish(), + prompt: z.string().nullish(), +}) + /** * AgentKnowledgeDatasetConfig */ @@ -3566,15 +3587,6 @@ export const zWorkflowNodeJobConfig = z.object({ workflow_prompt: z.string().optional().default(''), }) -/** - * AgentSuggestedQuestionsAfterAnswerFeatureConfig - */ -export const zAgentSuggestedQuestionsAfterAnswerFeatureConfig = z.object({ - enabled: z.boolean().optional().default(false), - model: zAgentSoulModelConfig.nullish(), - prompt: z.string().nullish(), -}) - /** * AgentSoulDifyToolCredentialRef * diff --git a/packages/dify-ui/src/file-tree/index.tsx b/packages/dify-ui/src/file-tree/index.tsx index 64555d3ca48..e368364b7ec 100644 --- a/packages/dify-ui/src/file-tree/index.tsx +++ b/packages/dify-ui/src/file-tree/index.tsx @@ -288,7 +288,7 @@ export function FileTreeIcon({ ? ( - + ) : @@ -319,7 +319,7 @@ export function FileTreeLabel({ const defaultProps = { 'data-label': labelText, 'className': cn( - 'min-w-0 truncate rounded-[5px] px-1 py-0.5', + 'w-0 min-w-0 flex-1 truncate rounded-[5px] px-1 py-0.5', labelText && 'after:invisible after:block after:h-0 after:overflow-hidden after:system-sm-medium after:content-[attr(data-label)]', 'system-sm-regular text-text-secondary group-data-[selected]/file-tree-row:system-sm-medium group-data-[selected]/file-tree-row:text-text-primary', className, diff --git a/packages/dify-ui/src/tooltip/index.tsx b/packages/dify-ui/src/tooltip/index.tsx index 359fb532e83..cd65da1727b 100644 --- a/packages/dify-ui/src/tooltip/index.tsx +++ b/packages/dify-ui/src/tooltip/index.tsx @@ -62,7 +62,7 @@ export function TooltipContent({ > ({ customPlaceholder, disabled, readonly, + footerNotice, }: { customPlaceholder?: string disabled?: boolean readonly?: boolean + footerNotice?: string }) => (
+ > + {footerNotice} +
), })) @@ -888,6 +892,15 @@ describe('Chat', () => { expect(screen.getByTestId('chat-input-area')).toBeInTheDocument() }) + it('should pass footer notice to ChatInputArea', () => { + renderChat({ + noChatInput: false, + footerNotice: 'Agent runs in a Linux sandbox.', + }) + + expect(screen.getByTestId('chat-input-area')).toHaveTextContent('Agent runs in a Linux sandbox.') + }) + it('should pass inputs and inputsForm to ChatInputArea', () => { const inputs = { field1: 'value1' } const inputsForm = [{ key: 'field1', type: 'text' }] diff --git a/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx index f8ae9aa3dc5..a1d49280a19 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/workflow-process.spec.tsx @@ -20,7 +20,7 @@ describe('WorkflowProcessItem', () => { it('should render the latest node title when collapsed', () => { render() - expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End') + expect(screen.getByRole('button', { name: /End/ })).toBeInTheDocument() expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument() }) @@ -36,7 +36,7 @@ describe('WorkflowProcessItem', () => { />, ) - expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('Invalid upload file') + expect(screen.getByRole('button', { name: /Invalid upload file/ })).toBeInTheDocument() }) it('should render "Workflow Process" title and TracingPanel when expanded', () => { @@ -65,7 +65,7 @@ describe('WorkflowProcessItem', () => { const user = userEvent.setup() render() - const header = screen.getByTestId('workflow-process-header') + const header = screen.getByRole('button', { name: /End/ }) // Expand await user.click(header) @@ -73,9 +73,9 @@ describe('WorkflowProcessItem', () => { expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument() // Collapse - await user.click(header) + await user.click(screen.getByRole('button', { name: /workflowProcess/ })) expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument() - expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End') + expect(screen.getByRole('button', { name: /End/ })).toBeInTheDocument() }) it('should render nothing if readonly is true', () => { @@ -86,27 +86,27 @@ describe('WorkflowProcessItem', () => { describe('Status Icons', () => { it('should show running spinner when status is Running', () => { render() - expect(screen.getByTestId('status-icon-running')).toBeInTheDocument() + expect(screen.getByRole('img', { name: /workflowProcessRunning/ })).toBeInTheDocument() }) it('should show success circle when status is Succeeded', () => { render() - expect(screen.getByTestId('status-icon-success')).toBeInTheDocument() + expect(screen.getByRole('img', { name: /workflowProcessSucceeded/ })).toBeInTheDocument() }) it('should show error warning when status is Failed', () => { render() - expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument() + expect(screen.getByRole('img', { name: /workflowProcessFailed/ })).toBeInTheDocument() }) it('should show error warning when status is Stopped', () => { render() - expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument() + expect(screen.getByRole('img', { name: /workflowProcessFailed/ })).toBeInTheDocument() }) it('should show pause circle when status is Paused', () => { render() - expect(screen.getByTestId('status-icon-paused')).toBeInTheDocument() + expect(screen.getByRole('img', { name: /workflowProcessPaused/ })).toBeInTheDocument() }) }) diff --git a/web/app/components/base/chat/chat/answer/workflow-process.tsx b/web/app/components/base/chat/chat/answer/workflow-process.tsx index 0427f9843b0..6b506221867 100644 --- a/web/app/components/base/chat/chat/answer/workflow-process.tsx +++ b/web/app/components/base/chat/chat/answer/workflow-process.tsx @@ -32,6 +32,15 @@ const WorkflowProcessItem = ({ const paused = data.status === WorkflowRunningStatus.Paused const latestNode = data.tracing[data.tracing.length - 1] const fallbackTitle = t('common.workflowProcess', { ns: 'workflow' }) + const statusLabel = running + ? t('common.workflowProcessRunning', { ns: 'workflow' }) + : succeeded + ? t('common.workflowProcessSucceeded', { ns: 'workflow' }) + : failed + ? t('common.workflowProcessFailed', { ns: 'workflow' }) + : paused + ? t('common.workflowProcessPaused', { ns: 'workflow' }) + : undefined const collapsedTitle = failed ? data.error || latestNode?.error || latestNode?.title || fallbackTitle : latestNode?.title || fallbackTitle @@ -58,40 +67,45 @@ const WorkflowProcessItem = ({ )} data-testid="workflow-process-item" > -
setCollapse(!collapse)} - data-testid="workflow-process-header" > { running && ( -
) } { succeeded && ( -
) } { failed && ( -
) } { paused && ( -
) } @@ -100,20 +114,19 @@ const WorkflowProcessItem = ({ 'min-w-0 grow truncate system-xs-medium', collapse && failed && data.error ? 'text-text-destructive' : 'text-text-secondary', )} - data-testid="workflow-process-title" > {!collapse ? fallbackTitle : collapsedTitle}
-
-
+ + { !collapse && (
{ failed && data.error && (
{data.error}
diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index 7fa4623b3a4..7db45d3f9b4 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -340,6 +340,27 @@ describe('ChatInputArea', () => { expect(screen.getByRole('button', { name: 'Start build' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.operation.send' })).not.toBeInTheDocument() }) + + it('should render the send button loading state when provided', async () => { + const user = userEvent.setup({ delay: null }) + const onSend = vi.fn() + render( + , + ) + + await user.type(getTextarea()!, 'Build an agent') + const startBuildButton = screen.getByRole('button', { name: 'Start build' }) + + expect(startBuildButton).toHaveAttribute('aria-disabled', 'true') + expect(startBuildButton.querySelector('[aria-hidden="true"]')).toBeInTheDocument() + await user.click(startBuildButton) + expect(onSend).not.toHaveBeenCalled() + }) }) // ------------------------------------------------------------------------- @@ -735,6 +756,20 @@ describe('ChatInputArea', () => { // ------------------------------------------------------------------------- describe('Feature Bar', () => { + it('should render footer notice with tooltip when provided', async () => { + const user = userEvent.setup({ delay: null }) + const footerNotice = 'Agent runs in a Linux sandbox.' + const footerNoticeTooltip = '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.' + render() + + expect(screen.getByText(footerNotice)).toBeInTheDocument() + expect(screen.queryByText(footerNoticeTooltip)).not.toBeInTheDocument() + + await user.hover(screen.getByRole('button', { name: footerNoticeTooltip })) + + expect(await screen.findByText(footerNoticeTooltip)).toBeInTheDocument() + }) + it('should render feature bar when showFeatureBar is true', () => { render() expect(screen.getByText(/feature.bar.empty/i)).toBeTruthy() diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index a30dddd2b2b..54a579540a0 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -1,9 +1,11 @@ +import type { ReactNode } from 'react' import type { Theme } from '../../embedded-chatbot/theme/theme-context' import type { EnableType, OnSend } from '../../types' import type { InputForm } from '../type' import type { FileUpload } from '@/app/components/base/features/types' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { noop } from 'es-toolkit/function' import { decode } from 'html-entities' import Recorder from 'js-audio-recorder' @@ -44,6 +46,9 @@ type ChatInputAreaProps = { isResponding?: boolean disabled?: boolean sendButtonLabel?: string + sendButtonLoading?: boolean + footerNotice?: ReactNode + footerNoticeTooltip?: ReactNode /** * Controls whether pressing Enter sends the message. * - true (default): Enter sends, Shift+Enter inserts newline @@ -52,7 +57,7 @@ type ChatInputAreaProps = { */ sendOnEnter?: boolean } -const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendButtonLabel, sendOnEnter = true }: ChatInputAreaProps) => { +const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendButtonLabel, sendButtonLoading, footerNotice, footerNoticeTooltip, sendOnEnter = true }: ChatInputAreaProps) => { const { t } = useTranslation() const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight() const [query, setQuery] = useState('') @@ -161,7 +166,9 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s toast.error(t('voiceInput.notAllow', { ns: 'common' })) }) }, [t]) - const operation = () + const operation = () + const shouldShowFooterNotice = footerNotice !== undefined && footerNotice !== null + const shouldShowFooterNoticeTooltip = footerNoticeTooltip !== undefined && footerNoticeTooltip !== null return ( <>
@@ -201,6 +208,31 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
{isMultipleLine && (
{operation}
)}
+ {shouldShowFooterNotice && ( +
+
+ {shouldShowFooterNoticeTooltip && ( + + + + + )} + /> + + {footerNoticeTooltip} + + + )} +
{footerNotice}
+
+
+ )} {showFeatureBar && (
diff --git a/web/app/components/base/chat/chat/chat-input-area/operation.tsx b/web/app/components/base/chat/chat/chat-input-area/operation.tsx index 542665cb574..bf6c2aa1381 100644 --- a/web/app/components/base/chat/chat/chat-input-area/operation.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/operation.tsx @@ -18,6 +18,7 @@ type OperationProps = { onShowVoiceInput?: () => void onSend: () => void sendButtonLabel?: string + sendButtonLoading?: boolean disabled?: boolean theme?: Theme | null ref?: Ref @@ -30,6 +31,7 @@ const Operation: FC = ({ onShowVoiceInput, onSend, sendButtonLabel, + sendButtonLoading, disabled, theme, }) => { @@ -68,6 +70,7 @@ const Operation: FC = ({ )} variant="primary" disabled={readonly || disabled} + loading={sendButtonLoading} onClick={onSend} style={ theme diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index dbb770af849..df1a1619042 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -52,6 +52,7 @@ type SendCallback = { onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise onConversationComplete?: (conversationId: string, workflowRunId?: string) => void + onUnhandledEvent?: IOtherOptions['onUnhandledEvent'] onSendSettled?: (hasError?: boolean) => void isPublicAPI?: boolean } @@ -684,6 +685,7 @@ export const useChat = ( onGetConversationMessages, onGetSuggestedQuestions, onConversationComplete, + onUnhandledEvent, onSendSettled, isPublicAPI, }: SendCallback, @@ -774,6 +776,7 @@ export const useChat = ( const otherOptions: IOtherOptions = { isPublicAPI, + onUnhandledEvent, getAbortController: (abortController) => { workflowEventsAbortControllerRef.current = abortController }, diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index b48973c2963..733abcae322 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -72,6 +72,9 @@ export type ChatProps = { inputPlaceholder?: string inputPlaceholderBotName?: string sendButtonLabel?: string + sendButtonLoading?: boolean + footerNotice?: ReactNode + footerNoticeTooltip?: ReactNode sidebarCollapseState?: boolean hideAvatar?: boolean sendOnEnter?: boolean @@ -121,6 +124,9 @@ const Chat: FC = ({ inputPlaceholder, inputPlaceholderBotName, sendButtonLabel, + sendButtonLoading, + footerNotice, + footerNoticeTooltip, sidebarCollapseState, hideAvatar, sendOnEnter, @@ -267,6 +273,9 @@ const Chat: FC = ({ isResponding={isResponding} readonly={readonly} sendButtonLabel={sendButtonLabel} + sendButtonLoading={sendButtonLoading} + footerNotice={footerNotice} + footerNoticeTooltip={footerNoticeTooltip} sendOnEnter={sendOnEnter} /> ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index 291b903f62a..6fd2876c9cb 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -3,6 +3,7 @@ import type { DefaultModel, Model, ModelItem } from '../../declarations' import { Combobox } from '@langgenius/dify-ui/combobox' import { createPreviewCardHandle } from '@langgenius/dify-ui/preview-card' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { ConfigurationMethodEnum, CustomConfigurationStatusEnum, @@ -27,16 +28,27 @@ vi.mock('../../hooks', async () => { } }) -vi.mock('../../model-badge', () => ({ - default: ({ children }: { children: ReactNode }) => {children}, -})) - vi.mock('../../model-icon', () => ({ default: ({ modelName }: { modelName: string }) => {modelName}, })) vi.mock('../../model-name', () => ({ - default: ({ modelItem, nameClassName }: { modelItem: ModelItem, nameClassName?: string }) => {modelItem.label.en_US}, + default: ({ + modelItem, + className, + nameClassName, + children, + }: { + modelItem: ModelItem + className?: string + nameClassName?: string + children?: ReactNode + }) => ( + + {modelItem.label.en_US} + {children} + + ), })) vi.mock('../feature-icon', () => ({ @@ -227,6 +239,52 @@ describe('PopupItem', () => { expect(screen.getByText('GPT-4')).toHaveClass('line-through') }) + it('should render incompatible model names with tertiary text color', () => { + renderWithCombobox( + modelItem.model !== 'gpt-4'} + onHide={vi.fn()} + />, + ) + + expect(screen.getByText('GPT-4o')).toHaveClass('text-text-secondary') + expect(screen.getByText('GPT-4o')).not.toHaveClass('text-text-quaternary') + expect(screen.getByText('GPT-4')).toHaveClass('text-text-quaternary') + }) + + it('should render suggestion icon with tooltip for suggested models', async () => { + renderWithCombobox( + modelItem.model === 'gpt-5.5'} + onHide={vi.fn()} + />, + ) + + expect(screen.getByText('GPT-5.5')).toBeInTheDocument() + expect(screen.getByText('GPT-5')).toBeInTheDocument() + const suggestionIcon = screen.getByLabelText('common.modelProvider.selector.suggestionTip') + + expect(suggestionIcon).toHaveClass('i-ri-shield-star-line') + + await userEvent.hover(suggestionIcon) + + expect(await screen.findByText('common.modelProvider.selector.suggestionTip')).toBeInTheDocument() + }) + it('should open model modal when clicking add on unconfigured model', () => { const onValueChange = vi.fn() const { rerender } = renderWithCombobox( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index fc86e82927c..978eb7e22dc 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -37,6 +37,7 @@ type ModelSelectorProps = { providerSettingsSource?: 'agent' showModelMeta?: boolean modelPredicate?: ModelSelectorModelPredicate + modelSuggestionPredicate?: ModelSelectorModelPredicate } function ModelSelector({ defaultModel, @@ -55,6 +56,7 @@ function ModelSelector({ providerSettingsSource, showModelMeta, modelPredicate, + modelSuggestionPredicate, }: ModelSelectorProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -173,6 +175,7 @@ function ModelSelector({ hideProviderSettingsFooter={hideProviderSettingsFooter} providerSettingsSource={providerSettingsSource} modelPredicate={modelPredicate} + modelSuggestionPredicate={modelSuggestionPredicate} onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined} onOpenMarketplace={onOpenMarketplace} onInputValueChange={setInputValue} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index f0d99838c55..0eb8182b718 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -1,10 +1,12 @@ import type { ComponentProps } from 'react' import type { DefaultModel, Model, ModelItem } from '../declarations' +import type { ModelSelectorModelPredicate } from './types' import { cn } from '@langgenius/dify-ui/cn' import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card' import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' @@ -29,6 +31,8 @@ type PreviewCardHandle = NonNullable[' type PopupItemProps = { defaultModel?: DefaultModel model: Model + modelPredicate?: ModelSelectorModelPredicate + modelSuggestionPredicate?: ModelSelectorModelPredicate previewCardHandle: PreviewCardHandle onPreviewCardClose: () => void onHide: () => void @@ -36,6 +40,8 @@ type PopupItemProps = { function PopupItem({ defaultModel, model, + modelPredicate, + modelSuggestionPredicate, previewCardHandle, onPreviewCardClose, onHide, @@ -44,6 +50,7 @@ function PopupItem({ const [dropdownOpen, setDropdownOpen] = useState(false) const { t } = useTranslation() const language = useLanguage() + const suggestionTip = t('modelProvider.selector.suggestionTip', { ns: 'common' }) const { setShowModelModal } = useModalContext() const { modelProviders } = useProviderContext() const updateModelList = useUpdateModelList() @@ -156,6 +163,8 @@ function PopupItem({
{!collapsed && model.models.map((modelItem) => { + const isModelCompatible = modelPredicate?.(model, modelItem) ?? true + const isModelSuggested = modelSuggestionPredicate?.(model, modelItem) ?? false const rowClassName = cn( 'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left', modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt', @@ -169,10 +178,30 @@ function PopupItem({ modelName={modelItem.model} /> + > + {isModelSuggested && ( + + + )} + /> + + {suggestionTip} + + + )} +
{ defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && ( 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 7b8c0bf4857..372317ff3c4 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 @@ -42,6 +42,7 @@ export type PopupProps = { hideProviderSettingsFooter?: boolean providerSettingsSource?: 'agent' modelPredicate?: ModelSelectorModelPredicate + modelSuggestionPredicate?: ModelSelectorModelPredicate onConfigureEmptyState?: () => void onInputValueChange: (value: string) => void onOpenMarketplace?: () => void @@ -55,6 +56,7 @@ function Popup({ hideProviderSettingsFooter, providerSettingsSource, modelPredicate, + modelSuggestionPredicate, onConfigureEmptyState, onInputValueChange, onOpenMarketplace, @@ -212,6 +214,8 @@ function Popup({ key={model.provider} defaultModel={defaultModel} model={model} + modelPredicate={modelPredicate} + modelSuggestionPredicate={modelSuggestionPredicate} previewCardHandle={previewCardHandle} onPreviewCardClose={handleClosePreviewCard} onHide={onHide} diff --git a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx index 1882b1eb89d..144c164ec02 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx @@ -31,6 +31,11 @@ export type AddOAuthButtonProps = { dividerClassName?: string disabled?: boolean onUpdate?: () => void + renderTrigger?: (props: { + disabled?: boolean + isConfigured: boolean + onClick: () => void + }) => React.ReactNode oAuthData?: { schema?: FormSchema[] is_oauth_custom_client_enabled?: boolean @@ -51,6 +56,7 @@ const AddOAuthButton = ({ dividerClassName, disabled, onUpdate, + renderTrigger, oAuthData, }: AddOAuthButtonProps) => { const { t } = useTranslation() @@ -67,8 +73,8 @@ const AddOAuthButton = ({ }, [oAuthData, data]) const { schema = [], - is_oauth_custom_client_enabled, - is_system_oauth_params_exists, + is_oauth_custom_client_enabled = false, + is_system_oauth_params_exists = false, client_params = {}, redirect_uri, } = mergedOAuthData @@ -186,7 +192,14 @@ const AddOAuthButton = ({ return ( <> { - isConfigured && ( + renderTrigger?.({ + disabled, + isConfigured, + onClick: isConfigured ? handleOAuth : openOAuthSettings, + }) + } + { + !renderTrigger && isConfigured && (
)} @@ -341,7 +348,7 @@ export function AgentBlockItem({ popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none" > - {t('roster.nodeSelector.dialogLabel')} + {t('roster.nodeSelector.dialogLabel', { ns: 'agentV2' })} { expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) }) + it('creates an inline agent binding when Agent v2 default data is pending inline', () => { + currentNodes = [ + createNode({ + id: 'node-1', + width: 100, + data: { + type: BlockEnum.Code, + title: 'Code', + desc: '', + }, + }), + ] + rfState.nodes = currentNodes as unknown as typeof rfState.nodes + rfState.edges = [] + rfState.setNodes.mockImplementation((nextNodes) => { + rfState.nodes = nextNodes + }) + runtimeNodesMetaDataMap.value = { + [BlockEnum.AgentV2]: { + defaultValue: { + type: BlockEnum.AgentV2, + title: 'Agent', + desc: '', + agent_binding: { + binding_type: 'inline_agent', + }, + agent_node_kind: 'dify_agent', + version: '2', + }, + metaData: { + isSingleton: false, + }, + }, + } + + const { result } = renderWorkflowHook(() => useNodesInteractions(), { + historyStore: { + nodes: currentNodes, + edges: [], + }, + }) + + act(() => { + result.current.handleNodeAdd( + { + nodeType: BlockEnum.AgentV2, + }, + { prevNodeId: 'node-1' }, + ) + }) + + const agentNode = rfState.nodes.find(node => node.data.type === BlockEnum.AgentV2) + const firstSetNodesPayload = rfState.setNodes.mock.calls[0]?.[0] + const pendingAgentNode = firstSetNodesPayload.find((node: Node) => node.data.type === BlockEnum.AgentV2) + + expect(pendingAgentNode?.data._isTempNode).toBe(true) + expect(agentNode?.data.agent_binding).toEqual({ + binding_type: 'inline_agent', + agent_id: 'inline-agent-1', + current_snapshot_id: 'inline-snapshot-1', + }) + expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith(agentNode?.id, expect.objectContaining({ + onSuccess: expect.any(Function), + })) + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) + }) + it('cancels selection state with collaborative nodes snapshot', () => { currentNodes = [ createNode({ diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index f06a0288edf..a9eea0c2995 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -88,16 +88,29 @@ const ENTRY_NODE_WRAPPER_OFFSET = { y: 21, // Adjusted based on visual testing feedback } as const -function needsPendingInlineAgentBinding(defaultValue?: BlockDefaultValue) { - if (!defaultValue || !('agent_binding' in defaultValue)) +function needsPendingInlineAgentBinding(defaultValue?: unknown) { + if (!defaultValue || typeof defaultValue !== 'object' || !('agent_binding' in defaultValue)) return false - const binding = defaultValue.agent_binding + const binding = (defaultValue as { + agent_binding?: { + agent_id?: string + binding_type?: string + current_snapshot_id?: string + } + }).agent_binding - return binding.binding_type === 'inline_agent' + return binding?.binding_type === 'inline_agent' && (!binding.agent_id || !binding.current_snapshot_id) } +function agentV2NodeDefaultsNeedInlineBinding(defaultValue?: unknown, pluginDefaultValue?: unknown) { + return needsPendingInlineAgentBinding({ + ...(defaultValue && typeof defaultValue === 'object' ? defaultValue : {}), + ...(pluginDefaultValue && typeof pluginDefaultValue === 'object' ? pluginDefaultValue : {}), + }) +} + const pruneClipboardNodesWithFilteredAncestors = ( sourceNodes: Node[], candidateNodes: Node[], @@ -926,7 +939,8 @@ export const useNodesInteractions = () => { return const { defaultValue } = nodeMetaData const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, nodeType, defaultValue) - const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 && needsPendingInlineAgentBinding(pluginDefaultValue) + const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 + && agentV2NodeDefaultsNeedInlineBinding(defaultValue, pluginDefaultValue) const { newNode, newIterationStartNode, newLoopStartNode } = generateNewNode({ type: getNodeCustomTypeByNodeDataType(nodeType), @@ -1506,7 +1520,8 @@ export const useNodesInteractions = () => { return const { defaultValue } = nodeMetaData const nodesWithSameType = getNodesWithSameDefaultDataType(nodes, nodeType, defaultValue) - const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 && needsPendingInlineAgentBinding(pluginDefaultValue) + const shouldCreateInlineAgentBinding = nodeType === BlockEnum.AgentV2 + && agentV2NodeDefaultsNeedInlineBinding(defaultValue, pluginDefaultValue) const { newNode: newCurrentNode, newIterationStartNode, diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts b/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts index ed7d9b23395..7460739778f 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/default.spec.ts @@ -93,6 +93,9 @@ describe('agent/default', () => { it('creates Agent v2 graph data by default', () => { expect(nodeDefault.defaultValue).toMatchObject({ + agent_binding: { + binding_type: 'inline_agent', + }, agent_node_kind: 'dify_agent', version: '2', }) 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 ba6dd76ee08..ecfdf41932e 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 @@ -194,6 +194,14 @@ vi.mock('@/service/client', () => ({ }, }, sandbox: { + get: { + queryOptions: () => ({ + queryKey: ['sandbox-info'], + queryFn: () => Promise.resolve({ + workspace_cwd: '.', + }), + }), + }, files: { get: { queryOptions: () => ({ @@ -406,9 +414,24 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { }), }) + fireEvent.click(await screen.findByRole('button', { + name: 'send build message', + })) + await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')) + expect(screen.queryByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.open', + })).not.toBeInTheDocument() + fireEvent.click(await screen.findByRole('button', { name: 'complete build conversation', })) + fireEvent.click(await screen.findByRole('button', { + name: 'send build message', + })) + expect(screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.workingDirectory.open', + })).toBeInTheDocument() + fireEvent.click(await screen.findByRole('button', { name: 'agentV2.agentDetail.configure.workingDirectory.open', })) diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx index 0d48cd93064..131378d292b 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/save-inline-agent-to-roster-dialog.spec.tsx @@ -117,7 +117,7 @@ describe('SaveInlineAgentToRosterDialog', () => { const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' }) const nameInput = within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }) expect(nameInput).toHaveValue('') - expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })).toHaveValue('Tender Analyst') + expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel common.label.optional' })).toHaveValue('Tender Analyst') expect(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder')).toHaveValue('Drafts tender clarifications.') await user.type(nameInput, 'Roster Tender Agent') 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 7b7e6fe0bb5..231ed9c74ba 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 @@ -257,6 +257,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ const composerState = inlineComposerState const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false) const [clearPreviewChat, setClearPreviewChat] = useState(false) + const [completedBuildConversationId, setCompletedBuildConversationId] = useState(null) const [workflowRunId, setWorkflowRunId] = useState(null) const conversationIds = useAtomValue(agentConfigureConversationIdsAtom) const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom) @@ -347,6 +348,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ }, [refreshDebugConversationInput, refreshDebugConversationRequestAsync]) const resetBuildChatSession = useCallback(async () => { await refreshDebugConversationAsync().catch(() => undefined) + setCompletedBuildConversationId(null) setConversationId({ mode: 'build', conversationId: null }) setWorkflowRunId(null) setClearPreviewChat(true) @@ -467,6 +469,14 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ || isApplyingInlineBuildDraft || discardBuildDraftMutation.isPending || isRefreshingDebugConversation + const buildConversationHasAgentResponse = !!conversationIds.build && ( + conversationIds.build === completedBuildConversationId + || ( + conversationIds.build === inlineComposerState?.debug_conversation_id + && (inlineComposerState?.debug_conversation_has_messages ?? false) + ) + ) + const showWorkingDirectoryAction = rightPanelChatMode === 'build' && buildConversationHasAgentResponse const restartCurrentChat = () => { if (isRestartCurrentChatDisabled) return @@ -539,6 +549,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ onOpenWorkingDirectory={workingDirectoryPanel.openWorkingDirectory} onRefresh={restartCurrentChat} refreshDisabled={isRestartCurrentChatDisabled} + showWorkingDirectoryAction={showWorkingDirectoryAction} showChatFeaturesAction={false} trailingAction={( + , + ) + + expect(screen.getByRole('article', { name: 'Web app' })).toBeInTheDocument() + }) + it('should copy the endpoint and render copied state from the clipboard hook', async () => { const user = userEvent.setup() const { rerender } = render( diff --git a/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx index d546c318313..4a7a3707f17 100644 --- a/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx +++ b/web/features/agent-v2/agent-detail/access/components/access-surface-card.tsx @@ -7,6 +7,7 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' import { useClipboard } from 'foxact/use-clipboard' +import { useId } from 'react' import { useTranslation } from 'react-i18next' export type AccessSurfaceCardProps = { @@ -44,6 +45,7 @@ export function AccessSurfaceCard({ }: AccessSurfaceCardProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') + const titleId = useId() const { copied, copy } = useClipboard({ timeout: 2000, onCopyError: () => { @@ -61,14 +63,14 @@ export function AccessSurfaceCard({ } return ( -
+
-

+

{title}

{badge} diff --git a/web/features/agent-v2/agent-detail/access/page.tsx b/web/features/agent-v2/agent-detail/access/page.tsx index 7e1d653fb70..edfef44976d 100644 --- a/web/features/agent-v2/agent-detail/access/page.tsx +++ b/web/features/agent-v2/agent-detail/access/page.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { consoleQuery } from '@/service/client' +import { AgentDetailSectionSurface } from '../section-surface' import { ServiceApiAccessCard } from './components/service-api-access-card' import { WebAppAccessCard } from './components/web-app-access-card' import { WorkflowReferencesTable } from './components/workflow-references-table' @@ -27,10 +28,7 @@ export function AgentAccessPage({ })) return ( -
+

@@ -77,6 +75,6 @@ export function AgentAccessPage({

- + ) } diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts index 4a8bbd0f5b5..75f9e827220 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts +++ b/web/features/agent-v2/agent-detail/configure/__tests__/model-compatibility.spec.ts @@ -4,7 +4,7 @@ import { ModelStatusEnum, ModelTypeEnum, } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { isAgentCompatibleModel } from '../model-compatibility' +import { isAgentCompatibleModel, isAgentSuggestedModel } from '../model-compatibility' const createModel = (provider: string): Model => ({ provider, @@ -25,47 +25,143 @@ const createModelItem = (model: string, overrides: Partial = {}): Mod ...overrides, }) +const createModelItemWithLabel = (model: string, label: string, overrides: Partial = {}): ModelItem => createModelItem(model, { + label: { en_US: label, zh_Hans: label }, + ...overrides, +}) + describe('isAgentCompatibleModel', () => { - it('should reject configured OpenAI models below the Agent-compatible baseline', () => { - const provider = createModel('langgenius/openai/openai') + it('should reject configured GPT models below the Agent-compatible baseline', () => { + const provider = createModel('any-provider') expect(isAgentCompatibleModel(provider, createModelItem('gpt-4o-mini'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('chatgpt-4o-latest'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('gpt-4.1-mini'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('gpt-4.1-nano'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-5'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-5-pro'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-5.1'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-5.2'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-5-mini'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-5-nano'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-5-chat'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-4o'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gpt-4.1'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('gpt-4'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('gpt-4-turbo'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('gpt-4-vision-preview'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('gpt-3.5-turbo-16k'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('o1'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('o1-preview'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('o3-mini'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('o3'))).toBe(false) expect(isAgentCompatibleModel(provider, createModelItem('o4-mini'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('o4'))).toBe(false) }) - it('should allow newer OpenAI models and other providers', () => { - expect(isAgentCompatibleModel(createModel('openai'), createModelItem('gpt-4o'))).toBe(true) - expect(isAgentCompatibleModel(createModel('openai'), createModelItem('gpt-4.1'))).toBe(true) - expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('other-model'))).toBe(true) + it('should allow models that are not in the blacklist', () => { + expect(isAgentCompatibleModel(createModel('any-provider'), createModelItem('gpt-5.5'))).toBe(true) + expect(isAgentCompatibleModel(createModel('any-provider'), createModelItem('other-model'))).toBe(true) }) it('should reject specifically configured models that do not meet the Agent baseline', () => { - expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('claude-3-haiku-20240307'))).toBe(false) - expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('claude-3.5-sonnet-20241022'))).toBe(false) - expect(isAgentCompatibleModel(createModel('langgenius/gemini/google'), createModelItem('gemini-2.5-flash-lite'))).toBe(false) - expect(isAgentCompatibleModel(createModel('langgenius/gemini/google'), createModelItem('gemini-1.5-flash-8b'))).toBe(false) - expect(isAgentCompatibleModel(createModel('deepseek'), createModelItem('deepseek-r1-distill-qwen-32b'))).toBe(false) - expect(isAgentCompatibleModel(createModel('minimax'), createModelItem('minimax-text-01'))).toBe(false) - expect(isAgentCompatibleModel(createModel('minimax'), createModelItem('minimax-m1'))).toBe(false) - expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen2.5-72b-instruct'))).toBe(false) - expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen2.5-coder-32b-instruct'))).toBe(false) - expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen3-30b'))).toBe(false) - expect(isAgentCompatibleModel(createModel('langgenius/zhipuai/zhipuai'), createModelItem('glm-4-airx'))).toBe(false) - expect(isAgentCompatibleModel(createModel('langgenius/zhipuai/zhipuai'), createModelItem('glm-z1-flash'))).toBe(false) + const provider = createModel('any-provider') + + expect(isAgentCompatibleModel(provider, createModelItem('claude-3-haiku-20240307'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('claude-3.5-sonnet-20241022'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gemini-2.5-flash-lite'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('Gemini 2.5 Flash'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('Gemini 2.0 Flash'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gemini-2.5-pro-preview'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('Gemini 3.1 Flash-Lite Preview'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('gemini-1.5-flash-8b'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('Nano Banana Pro'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('grok-code-fast'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('grok-code-fast-1'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('grok-4-beta'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('grok-2'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('grok-3-mini'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('deepseek-chat'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('deepseek-coder'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('deepseek-reasoner'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('deepseek-chat-v3'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('deepseek-R1'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('DeepSeek-R1-Distill-Qwen-32B'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('deepseek-v3.1'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('abab6.5-chat'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('minimax-text-01'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('minimax-m1'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('minimax-m2'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('minimax-m2-chat'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('kimi-k2-thinking'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('moonshot-v1-128k'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qvq-max'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwq-plus'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen-max'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen2.5-72b-instruct'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen2.5-coder-32b-instruct'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen3-coder-plus'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen3.5-max'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen3.7-flash'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('qwen-flash'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('farui-plus'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('chatglm-3-turbo'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('glm-3-turbo'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('glm-4-airx'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('glm-4.7'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('glm-z1-flash'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItem('glm-z2-plus'))).toBe(false) }) - it('should allow unconfigured models from the same providers', () => { - expect(isAgentCompatibleModel(createModel('anthropic'), createModelItem('claude-sonnet-4'))).toBe(true) - expect(isAgentCompatibleModel(createModel('langgenius/gemini/google'), createModelItem('gemini-2.5-pro'))).toBe(true) - expect(isAgentCompatibleModel(createModel('deepseek'), createModelItem('deepseek-coder'))).toBe(true) - expect(isAgentCompatibleModel(createModel('tongyi'), createModelItem('qwen3-coder-plus'))).toBe(true) - expect(isAgentCompatibleModel(createModel('langgenius/zhipuai/zhipuai'), createModelItem('glm-4.7'))).toBe(true) + it('should ignore provider when evaluating blacklist patterns', () => { + expect(isAgentCompatibleModel(createModel('custom-provider'), createModelItem('gpt-4o'))).toBe(false) + expect(isAgentCompatibleModel(createModel('custom-provider'), createModelItem('claude-3-haiku-20240307'))).toBe(false) + expect(isAgentCompatibleModel(createModel('openai'), createModelItem('claude-sonnet-4'))).toBe(true) + }) + + it('should evaluate blacklist patterns against the English model label', () => { + const provider = createModel('any-provider') + + expect(isAgentCompatibleModel(provider, createModelItemWithLabel('model-id', 'gpt-4o'))).toBe(false) + expect(isAgentCompatibleModel(provider, createModelItemWithLabel('gpt-4o', 'safe-model-label'))).toBe(true) + }) + + it('should allow unconfigured models from providers with blacklisted model families', () => { + const provider = createModel('any-provider') + + expect(isAgentCompatibleModel(provider, createModelItem('claude-sonnet-4'))).toBe(true) + expect(isAgentCompatibleModel(provider, createModelItem('gemini-2.5-pro'))).toBe(true) + expect(isAgentCompatibleModel(provider, createModelItem('Gemini 2.5 Pro'))).toBe(true) + expect(isAgentCompatibleModel(provider, createModelItem('grok-4'))).toBe(true) + expect(isAgentCompatibleModel(provider, createModelItem('qwen3.7-max'))).toBe(true) + }) +}) + +describe('isAgentSuggestedModel', () => { + it('should suggest configured Agent baseline models by English label', () => { + const provider = createModel('any-provider') + + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('gpt-5.5-pro'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('Claude Opus 4.8'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('opus-4.7'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('Claude Sonnet 4.6'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('Gemini 3.1 Pro Preview'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('Gemini 3.1 Pro Preview 001'))).toBe(false) + expect(isAgentSuggestedModel(provider, createModelItem('grok-4.3'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('deepseek-v4-pro'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('kimi-k2.6'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('minimax-m3'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('qwen3.7-max'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('qwen3-coder-plus'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('glm-5.1'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItem('other-model'))).toBe(false) + }) + + it('should evaluate suggestions against the English model label', () => { + const provider = createModel('any-provider') + + expect(isAgentSuggestedModel(provider, createModelItemWithLabel('model-id', 'GPT 5.5 Pro'))).toBe(true) + expect(isAgentSuggestedModel(provider, createModelItemWithLabel('gpt-5.5', 'safe-model-label'))).toBe(false) }) }) 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 fdd0e299929..cdce3ee690a 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 @@ -230,6 +230,7 @@ vi.mock('../components/orchestrate', async () => { vi.mock('../components/orchestrate/build-draft-bar', () => ({ AgentBuildDraftBar: (props: { + changeSummary?: unknown changesCount: number disabled?: boolean onApply: () => void @@ -324,6 +325,7 @@ vi.mock('../components/preview/header', () => ({ onOpenWorkingDirectory: () => void onRefresh: () => void refreshDisabled?: boolean + showWorkingDirectoryAction?: boolean }) => (
{props.mode}
@@ -336,9 +338,11 @@ vi.mock('../components/preview/header', () => ({ - + {props.showWorkingDirectoryAction && ( + + )} @@ -805,6 +809,120 @@ describe('AgentConfigurePage', () => { expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled() }) + it('should show the working directory action after the first build reply completes', async () => { + const queryClient = new QueryClient() + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'open working directory' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'send build message' })) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes') + }) + expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'open working directory' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' })) + + expect(screen.getByRole('button', { name: 'open working directory' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'send build message' })) + + expect(screen.getByRole('button', { name: 'open working directory' })).toBeInTheDocument() + }) + + it('should hide the working directory action when the build chat has no conversation', () => { + const queryClient = new QueryClient() + mocks.queryState.agent = { + ...mocks.queryState.agent, + data: { + ...mocks.queryState.agent.data, + debug_conversation_has_messages: false, + debug_conversation_id: '', + debug_conversation_message_count: 0, + }, + } + mocks.queryState.composer = { + data: { + agent_soul: { + prompt: { + system_prompt: 'draft prompt', + }, + }, + }, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + mocks.queryState.buildDraft = { + data: { + agent_soul: { + prompt: { + system_prompt: 'build prompt', + }, + }, + draft: {}, + variant: 'agent_app', + }, + dataUpdatedAt: 1, + error: null, + isFetching: false, + isError: false, + isPending: false, + isSuccess: true, + refetch: vi.fn(), + } + + render( + + + , + ) + + expect(screen.queryByRole('button', { name: 'open working directory' })).not.toBeInTheDocument() + }) + it('should show chat features from the active build draft source as read-only', async () => { const user = userEvent.setup() const queryClient = new QueryClient() 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 0bc0be92734..ad402fb2e38 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 @@ -2,6 +2,7 @@ import type { PropsWithChildren } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' +import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { agentComposerDraftAtom, agentComposerPublishedDraftAtom } from '@/features/agent-v2/agent-composer/store' import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files' @@ -437,7 +438,7 @@ describe('useAgentConfigureSync', () => { })) }) - it('should skip autosave when knowledge retrieval validation fails', async () => { + it('should autosave when knowledge retrieval validation fails', async () => { const { result, store } = renderUseAgentConfigureSync() act(() => { @@ -457,8 +458,8 @@ describe('useAgentConfigureSync', () => { await vi.advanceTimersByTimeAsync(5000) }) - expect(composerPutMutationFn).not.toHaveBeenCalled() - expect(result.current.draftSavedAt).toBeUndefined() + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) + expect(result.current.draftSavedAt).toBeDefined() }) it('should keep autosave failures silent and leave the local draft dirty', async () => { @@ -582,7 +583,7 @@ describe('useAgentConfigureSync', () => { })) }) - it('should reject manual save when knowledge retrieval validation fails', async () => { + it('should save draft manually when knowledge retrieval validation fails', async () => { const { result, store } = renderUseAgentConfigureSync() act(() => { @@ -598,8 +599,11 @@ describe('useAgentConfigureSync', () => { }) }) - await expect(result.current.saveDraft()).rejects.toThrow('Agent knowledge retrieval configuration is invalid.') - expect(composerPutMutationFn).not.toHaveBeenCalled() + await act(async () => { + await result.current.saveDraft() + }) + + expect(composerPutMutationFn).toHaveBeenCalledTimes(1) }) it('should publish only when publishDraft is called explicitly', async () => { @@ -755,7 +759,7 @@ describe('useAgentConfigureSync', () => { expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed') }) - it('should reject publish when knowledge retrieval validation fails', async () => { + it('should toast and skip publish when knowledge retrieval validation fails', async () => { const { result, store } = renderUseAgentConfigureSync() act(() => { @@ -771,9 +775,39 @@ describe('useAgentConfigureSync', () => { }) }) - await expect(result.current.publishDraft()).rejects.toThrow('Agent knowledge retrieval configuration is invalid.') + await act(async () => { + await result.current.publishDraft() + }) + expect(composerPutMutationFn).not.toHaveBeenCalled() expect(publishAgentMutationFn).not.toHaveBeenCalled() + expect(toastMock.error).toHaveBeenCalledWith('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}') + }) + + it('should toast metadata filtering model error when publishing with automatic metadata filtering and no model', async () => { + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [{ id: 'dataset-1', name: 'Docs' }], + metadataFilterMode: MetadataFilteringModeEnum.automatic, + }, + ], + }) + }) + + await act(async () => { + await result.current.publishDraft() + }) + + expect(composerPutMutationFn).not.toHaveBeenCalled() + expect(publishAgentMutationFn).not.toHaveBeenCalled() + expect(toastMock.error).toHaveBeenCalledWith('agentV2.agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired') }) it('should expose publishing status from the publish mutation while publish is pending', async () => { 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 c9a53698b30..ac5e8b886ea 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 @@ -216,6 +216,7 @@ function AgentConfigurePageComposerContent({ } = configureData const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false) const [clearPreviewChat, setClearPreviewChat] = useState(false) + const [completedBuildConversationId, setCompletedBuildConversationId] = useState(null) const conversationIds = useAtomValue(agentConfigureConversationIdsAtom) const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom) const workingDirectoryPanel = useAgentWorkingDirectoryPanel({ @@ -237,6 +238,7 @@ function AgentConfigurePageComposerContent({ await onRefreshDebugConversationAsync() } finally { + setCompletedBuildConversationId(null) setConversationId({ mode: 'build', conversationId: null }) setClearPreviewChat(true) } @@ -288,6 +290,14 @@ function AgentConfigurePageComposerContent({ || buildDraftActions.isApplyingBuildDraft || buildDraftActions.isDiscardingBuildDraft const isChatFeaturesReadOnly = (isViewingVersion && versionQuery.isPending) || buildDraft.isActive + const buildConversationHasAgentResponse = !!conversationIds.build && ( + conversationIds.build === completedBuildConversationId + || ( + conversationIds.build === agentQuery.data?.debug_conversation_id + && (agentQuery.data?.debug_conversation_has_messages ?? false) + ) + ) + const showWorkingDirectoryAction = rightPanelChatMode === 'build' && buildConversationHasAgentResponse const restartCurrentChat = () => { if (isRestartCurrentChatDisabled) return @@ -326,6 +336,7 @@ function AgentConfigurePageComposerContent({ bottomAction={showBuildDraftBar ? ( )} chat={( @@ -381,6 +393,7 @@ function AgentConfigurePageComposerContent({ onClearChatListChange={setClearPreviewChat} onConversationComplete={(mode, completedConversationId) => { if (mode === 'build') { + setCompletedBuildConversationId(completedConversationId) invalidateAgentWorkingDirectoryFiles({ agentId, conversationId: completedConversationId, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/bottom-actions.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/bottom-actions.spec.tsx new file mode 100644 index 00000000000..084599d650c --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/bottom-actions.spec.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { AgentOrchestrateBottomActions } from '../bottom-actions' + +describe('AgentOrchestrateBottomActions', () => { + it('should allow callers to keep the bottom action width when nested content opens', () => { + const { rerender } = render( + +
+ , + ) + + expect(screen.getByTestId('bottom-action').parentElement).toHaveClass('has-[[data-open]]:max-w-96') + + rerender( + +
+ , + ) + + expect(screen.getByTestId('bottom-action').parentElement).not.toHaveClass('has-[[data-open]]:max-w-96') + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx index 91d34550e2f..2b16ac7456d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/build-draft-bar.spec.tsx @@ -1,7 +1,30 @@ +import type { AgentBuildDraftChangeSummary } from '../build-draft-changes-context' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AgentBuildDraftBar } from '../build-draft-bar' +const changeSummary: AgentBuildDraftChangeSummary = { + changedKeys: ['skills', 'files', 'envVariables'], + changesCount: 5, + skills: [ + { id: 'skill-1', name: 'tender-analyzer', operation: 'added' }, + { id: 'skill-2', name: 'figma-code-connect', operation: 'removed' }, + ], + files: [ + { + id: '__agent_config_build_note__', + name: 'build_note.md', + operation: 'updated', + icon: 'markdown', + descriptionKey: 'agentDetail.configure.buildDraft.buildNoteDescription', + }, + { id: 'file-1', name: 'index.json', operation: 'added', icon: 'json' }, + ], + envVariables: [ + { id: 'env-1', name: 'API_KEY', operation: 'updated' }, + ], +} + describe('AgentBuildDraftBar', () => { it('should disable both build draft actions when the bar is disabled', async () => { const user = userEvent.setup() @@ -72,20 +95,25 @@ describe('AgentBuildDraftBar', () => { expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })).toBeDisabled() }) - it('should not show build draft change metadata', () => { - const { rerender } = render( + it('should show build draft change metadata', () => { + const { container, rerender } = render( , ) + expect(container.firstElementChild).toHaveClass('w-full') + expect(container.firstElementChild).not.toHaveClass('w-fit') expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.title')).toBeInTheDocument() - expect(screen.getAllByText(/^agentV2\.agentDetail\.configure\.buildDraft\./)).toHaveLength(2) + expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":0}' })).toBeInTheDocument() + expect(document.querySelector('.i-ri-arrow-right-s-line')).toBeInTheDocument() rerender( { ) expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.title')).toBeInTheDocument() - expect(screen.getAllByText(/^agentV2\.agentDetail\.configure\.buildDraft\./)).toHaveLength(2) + expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":2}' })).toBeInTheDocument() + }) + + it('should open build draft change details from the metadata trigger', async () => { + const user = userEvent.setup() + const onApply = vi.fn() + const onDiscard = vi.fn() + const getBoundingClientRectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ + bottom: 50, + height: 50, + left: 0, + right: 432, + top: 0, + width: 432, + x: 0, + y: 0, + toJSON: () => ({}), + }) + + render( + , + ) + + const changesTrigger = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.changesToApply:{"count":5}' }) + + await user.click(changesTrigger) + + const changesPanelId = changesTrigger.getAttribute('aria-controls') + expect(changesPanelId).toBeTruthy() + expect(document.getElementById(changesPanelId!)).toHaveStyle({ width: '432px' }) + expect(screen.getByText('agentV2.agentDetail.configure.skills.label')).toBeInTheDocument() + expect(screen.getByText('tender-analyzer')).toBeInTheDocument() + expect(screen.getByText('figma-code-connect')).toBeInTheDocument() + expect(screen.getByText('build_note.md')).toBeInTheDocument() + expect(screen.getByText('agentV2.agentDetail.configure.buildDraft.buildNoteDescription')).toBeInTheDocument() + expect(screen.getByText('index.json')).toBeInTheDocument() + expect(screen.getByText('agentV2.agentDetail.configure.advancedSettings.envEditor.shortLabel')).toBeInTheDocument() + expect(screen.getByText('API_KEY')).toBeInTheDocument() + expect(document.querySelector('.text-text-accent')).toHaveClass('i-ri-add-circle-fill') + + await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })) + await user.click(screen.getByRole('button', { name: 'custom.apply' })) + + expect(onDiscard).toHaveBeenCalledTimes(1) + expect(onApply).toHaveBeenCalledTimes(1) + + getBoundingClientRectSpy.mockRestore() }) it('should keep both actions enabled when there are no build draft changes', async () => { @@ -111,10 +192,7 @@ describe('AgentBuildDraftBar', () => { const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' }) const applyButton = screen.getByRole('button', { name: 'custom.apply' }) - const buttons = screen.getAllByRole('button') - expect(buttons[0]).toBe(discardButton) - expect(buttons[1]).toBe(applyButton) expect(discardButton).toBeEnabled() expect(applyButton).toBeEnabled() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx index 7ba52ad0841..a2400bc8e72 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx @@ -250,7 +250,7 @@ describe('AgentConfigurePublishBar', () => { ) }) - it('should block publish when knowledge retrieval validation fails', () => { + it('should allow publish request when knowledge retrieval validation fails', async () => { const { onPublish } = renderPublishBar({ setupStore: (store) => { store.set(agentComposerDraftAtom, { @@ -266,15 +266,17 @@ describe('AgentConfigurePublishBar', () => { }, }) - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeDisabled() - expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeEnabled() + expect(screen.queryByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}')).not.toBeInTheDocument() expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( - expect.objectContaining({ enabled: false, ignoreInputs: false }), + expect.objectContaining({ enabled: true, ignoreInputs: false }), ) fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) - expect(onPublish).not.toHaveBeenCalled() + await waitFor(() => { + expect(onPublish).toHaveBeenCalledTimes(1) + }) }) it('should restore the selected version from view-only mode', async () => { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx index cd61ce1b2f1..bf798ba9b8f 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/bottom-actions.tsx @@ -1,9 +1,12 @@ import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' export function AgentOrchestrateBottomActions({ children, + shrinkOnOpen = true, }: { children: ReactNode + shrinkOnOpen?: boolean }) { return (
{children}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx index 924f3e44f15..f053d47287d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar.tsx @@ -1,10 +1,15 @@ 'use client' +import type { AgentBuildDraftChangeSummary } from './build-draft-changes-context' import { Button } from '@langgenius/dify-ui/button' +import { CollapsiblePanel, CollapsibleRoot } from '@langgenius/dify-ui/collapsible' +import { useId, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { AgentBuildGridTexture } from '../build-grid-texture' +import { AgentBuildDraftChangesPanel } from './build-draft-changes-panel' type AgentBuildDraftBarProps = { + changeSummary?: AgentBuildDraftChangeSummary changesCount: number disabled?: boolean isApplying?: boolean @@ -14,6 +19,8 @@ type AgentBuildDraftBarProps = { } export function AgentBuildDraftBar({ + changeSummary, + changesCount, disabled = false, isApplying = false, isDiscarding = false, @@ -22,42 +29,85 @@ export function AgentBuildDraftBar({ }: AgentBuildDraftBarProps) { const { t } = useTranslation('agentV2') const { t: tCustom } = useTranslation('custom') + const [open, setOpen] = useState(false) + const [panelWidth, setPanelWidth] = useState() + const collapsedBarRef = useRef(null) + const changesPanelId = useId() const isActionPending = isApplying || isDiscarding const applyDisabled = disabled || isActionPending const discardDisabled = disabled || isActionPending + const changesLabel = t('agentDetail.configure.buildDraft.changesToApply', { count: changesCount }) + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + const width = collapsedBarRef.current?.getBoundingClientRect().width + + if (width) + setPanelWidth(width) + } + else { + setPanelWidth(undefined) + } + + setOpen(nextOpen) + } return ( -
+ -
-

- {t('agentDetail.configure.buildDraft.title')} -

+ + handleOpenChange(!open)} + /> + +
+
+

+ {t('agentDetail.configure.buildDraft.title')} +

+ +
+ +
- - -
+
) } diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts index 111952903cd..7454049d52d 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-context.ts @@ -1,11 +1,31 @@ 'use client' +import type { FileTreeIconType } from '@langgenius/dify-ui/file-tree' import type { ReactNode } from 'react' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' +import type { I18nKeysWithPrefix } from '@/types/i18n' import { createContext, createElement, useContext, useMemo } from 'react' export type AgentBuildDraftChangedKey = keyof AgentSoulConfigFormState +type AgentBuildDraftChangeOperation = 'added' | 'removed' | 'updated' + +export type AgentBuildDraftChangeItem = { + id: string + name: string + operation: AgentBuildDraftChangeOperation + icon?: FileTreeIconType + descriptionKey?: I18nKeysWithPrefix<'agentV2', 'agentDetail.configure.buildDraft.'> +} + +export type AgentBuildDraftChangeSummary = { + changedKeys: readonly AgentBuildDraftChangedKey[] + changesCount: number + skills: readonly AgentBuildDraftChangeItem[] + files: readonly AgentBuildDraftChangeItem[] + envVariables: readonly AgentBuildDraftChangeItem[] +} + export type AgentBuildDraftChangeSection = | 'skills' | 'files' diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-panel.tsx new file mode 100644 index 00000000000..01cc465bad0 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-changes-panel.tsx @@ -0,0 +1,147 @@ +'use client' + +import type { TFunction } from 'i18next' +import type { AgentBuildDraftChangeItem, AgentBuildDraftChangeSummary } from './build-draft-changes-context' +import { cn } from '@langgenius/dify-ui/cn' +import { FileTreeIcon } from '@langgenius/dify-ui/file-tree' +import { useTranslation } from 'react-i18next' + +type AgentBuildDraftChangeSection = { + key: string + label: string + items?: readonly AgentBuildDraftChangeItem[] +} + +export function AgentBuildDraftChangesPanel({ + changeSummary, + changesLabel, + onToggle, +}: { + changeSummary?: AgentBuildDraftChangeSummary + changesLabel: string + onToggle: () => void +}) { + const { t } = useTranslation('agentV2') + const sections = getChangeSections({ changeSummary, t }) + + return ( +
+
+

+ {t('agentDetail.configure.buildDraft.title')} +

+ +
+
+ {sections.map(section => ( + + ))} +
+
+ ) +} + +function AgentBuildDraftChangeSectionRow({ + section, +}: { + section: AgentBuildDraftChangeSection +}) { + return ( +
+
+ +

+ {section.label} +

+
+ {section.items?.length + ? ( +
+ {section.items.map(item => ( + + ))} +
+ ) + : ( + null + )} +
+ ) +} + +function AgentBuildDraftChangeItemRow({ + item, +}: { + item: AgentBuildDraftChangeItem +}) { + const { t } = useTranslation('agentV2') + + return ( +
+
+ +
+ {item.icon + ? + : } +

+ {item.name} +

+
+
+ {item.descriptionKey && ( +

+ {t(item.descriptionKey)} +

+ )} +
+ ) +} + +function getChangeSections({ + changeSummary, + t, +}: { + changeSummary?: AgentBuildDraftChangeSummary + t: TFunction<'agentV2'> +}): AgentBuildDraftChangeSection[] { + if (!changeSummary) + return [] + + const sections: AgentBuildDraftChangeSection[] = [] + const pushItemSection = (key: string, label: string, items: readonly AgentBuildDraftChangeItem[]) => { + if (items.length === 0) + return + + sections.push({ + key, + label, + items, + }) + } + + pushItemSection('skills', t('agentDetail.configure.skills.label'), changeSummary.skills) + pushItemSection('files', t('agentDetail.configure.files.label'), changeSummary.files) + pushItemSection('envVariables', t('agentDetail.configure.advancedSettings.envEditor.shortLabel'), changeSummary.envVariables) + + return sections +} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/docs-link.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/docs-link.tsx new file mode 100644 index 00000000000..bc530dc4ba5 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/docs-link.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' + +export function DocsLink({ + children, + href, +}: { + children?: ReactNode + href: string +}) { + return ( + + {children} + + ) +} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx index 7edbcbe6a27..2b393b1ed35 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/tip-content.tsx @@ -1,30 +1,11 @@ 'use client' -import type { ReactNode } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' +import { DocsLink } from './docs-link' type AgentConfigureTipContentProps = { - type: 'prompt' | 'skills' | 'files' | 'tools' | 'env' -} - -function DocsLink({ - children, - href, -}: { - children?: ReactNode - href: string -}) { - return ( - - {children} - - ) + type: 'prompt' | 'skills' | 'files' | 'tools' | 'knowledge' | 'env' } export function AgentConfigureTipContent({ type }: AgentConfigureTipContentProps) { @@ -47,13 +28,15 @@ export function AgentConfigureTipContent({ type }: AgentConfigureTipContentProps if (type === 'skills') { return ( - , - }} - /> + + , + }} + /> + ) } @@ -64,13 +47,27 @@ export function AgentConfigureTipContent({ type }: AgentConfigureTipContentProps i18nKey="agentDetail.configure.tools.richTip" ns="agentV2" components={{ - pluginDocLink: , - buildDocLink: , + docLink: , }} /> ) } + if (type === 'knowledge') { + return ( + , + }} + /> + ) + } + + if (type === 'files') + return {t('agentDetail.configure.files.tip')} + return <>{t(`agentDetail.configure.${type}.tip`)} } 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 d6921776e3e..028bd17f304 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 @@ -125,8 +125,8 @@ function ConfigSnapshotProbe() { ) } -function renderAgentFiles({ - initialDraft = { +function createInitialDraft(overrides: Partial = {}): AgentSoulConfigFormState { + return { ...defaultAgentSoulConfigFormState, files: [ { @@ -144,7 +144,12 @@ function renderAgentFiles({ configName: 'brief.md', }, ], - } satisfies AgentSoulConfigFormState, + ...overrides, + } +} + +function renderAgentFiles({ + initialDraft = createInitialDraft(), initialOriginalConfig, apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext, readOnly = false, @@ -366,9 +371,7 @@ describe('AgentFiles', () => { it('should show config note as a virtual build note file and preview its content locally', async () => { const user = userEvent.setup() renderAgentFiles({ - initialOriginalConfig: { - config_note: 'Build context from the latest build chat.', - }, + initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), }) expect(screen.getByText('build_note.md')).toBeInTheDocument() @@ -396,6 +399,36 @@ describe('AgentFiles', () => { })) }) + it('should show generated build note metadata with an explanatory infotip', async () => { + const user = userEvent.setup() + renderAgentFiles({ + initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), + }) + + expect(screen.getByText('agentV2.agentDetail.configure.files.buildNote.generated')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.buildNote.tooltip' })) + + expect(await screen.findByText('agentDetail.configure.files.buildNote.richTooltip')).toBeInTheDocument() + }) + + it('should clear config note when deleting the virtual build note file', async () => { + const user = userEvent.setup() + renderAgentFiles({ + initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }), + }) + + await user.click(screen.getByRole('button', { + name: /agentV2\.agentDetail\.configure\.files\.remove.*build_note\.md/, + })) + + expect(screen.queryByText('build_note.md')).not.toBeInTheDocument() + expect(mocks.deleteFileMutationFn).not.toHaveBeenCalled() + + const snapshot = JSON.parse(screen.getByTestId('config-snapshot-probe').textContent ?? '{}') + expect(snapshot.config_note).toBe('') + }) + it('should keep flat config files visible without drive-prefix filtering and disable add in read-only mode', () => { renderAgentFiles({ readOnly: true }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx index 54099b7fad5..da9fa0bbc35 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx @@ -4,22 +4,29 @@ import type { ReactNode } from 'react' import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { AgentConfigApiContext } from '../config-context' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' +import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogTrigger, } from '@langgenius/dify-ui/dialog' import { + FileTreeBadge, FileTreeGuide, + FileTreeIcon, + FileTreeLabel, } from '@langgenius/dify-ui/file-tree' import { useMutation, useQuery } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { useCallback, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { agentComposerOriginalConfigAtom } from '@/features/agent-v2/agent-composer/store' +import { Trans, useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' +import { useDocLink } from '@/context/i18n' +import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files' import { consoleQuery } from '@/service/client' import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context' import { ConfigureSectionAddButton } from '../common/add-button' +import { DocsLink } from '../common/docs-link' import { ConfigureSectionEmpty } from '../common/empty' import { ConfigureSection } from '../common/section' import { AgentConfigureTipContent } from '../common/tip-content' @@ -91,6 +98,7 @@ function AgentFileItem({ const selectedFile = selectedFileId ? findAgentFileNode(files, selectedFileId) : undefined const selectedPreviewFile = selectedFile ?? file const isVirtualPreviewFile = selectedPreviewFile.virtualContent !== undefined + const isBuildNoteFile = file.id === BUILD_NOTE_FILE_ID const previewFileId = isVirtualPreviewFile ? undefined : getAgentFilePreviewKey(selectedPreviewFile) const agentPreviewQuery = useQuery({ ...consoleQuery.agent.byAgentId.config.files.byName.preview.get.queryOptions({ @@ -176,14 +184,17 @@ function AgentFileItem({ type="button" data-selected={selected || undefined} aria-current={selected ? 'true' : undefined} - className="group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md pr-7 pl-2 text-left outline-hidden select-none hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid data-[selected]:bg-state-base-active" + className={cn( + 'group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none group-hover/file-row:bg-state-base-hover hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid data-[selected]:bg-state-base-active', + 'pr-7', + )} /> )} > {Array.from({ length: Math.max(depth - 1, 0) }, (_, index) => ( ))} -
+
{children}
@@ -209,7 +220,12 @@ function AgentFileItem({ }} /> - {!readOnly && !file.virtualContent && ( + {isBuildNoteFile && ( + + )} + {!readOnly && (!file.virtualContent || isBuildNoteFile) && ( @@ -29,6 +53,25 @@ vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/add-data }, })) +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ + modelList: [{ + provider: 'rerank-provider', + models: [{ model: 'rerank-model' }], + }], + defaultModel: { + provider: { + provider: 'rerank-provider', + }, + model: 'rerank-model', + }, + })), + useCurrentProviderAndModel: vi.fn(() => ({ + currentProvider: { provider: 'rerank-provider' }, + currentModel: { model: 'rerank-model' }, + })), +})) + const agentKnowledgeDraft = { ...defaultAgentSoulConfigFormState, knowledgeRetrievals: [ @@ -147,7 +190,7 @@ describe('AgentKnowledgeRetrieval', () => { expect(within(dialog).getByRole('textbox', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', })).toHaveValue('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo') - expect(within(dialog).getByText('appDebug.datasetConfig.knowledgeTip')).toBeInTheDocument() + expect(within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip')).not.toBeInTheDocument() expect(within(dialog).getByRole('button', { name: 'common.operation.add workflow.nodes.knowledgeRetrieval.knowledge', })).toBeInTheDocument() @@ -220,6 +263,33 @@ describe('AgentKnowledgeRetrieval', () => { expect(within(dialog).getByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel"}')).toBeInTheDocument() }) + it('should not show inline validation for automatic metadata filtering without a model', async () => { + const user = userEvent.setup() + renderKnowledgeRetrieval({ + initialDraft: { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [{ id: 'dataset-1', name: 'Docs' }], + metadataFilterMode: MetadataFilteringModeEnum.automatic, + }, + ], + }, + }) + + await user.click(screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Docs Search"}', + })) + + const dialog = screen.getByRole('dialog', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', + }) + + expect(within(dialog).queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.validation.metadataModelRequired')).not.toBeInTheDocument() + }) + it('should show duplicate-name validation in the dialog', async () => { const user = userEvent.setup() renderKnowledgeRetrieval({ @@ -308,6 +378,12 @@ describe('AgentKnowledgeRetrieval', () => { }, retrieval: expect.objectContaining({ mode: 'multiple', + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_model: { + provider: 'rerank-provider', + model: 'rerank-model', + }, top_k: 4, }), }), @@ -367,6 +443,63 @@ describe('AgentKnowledgeRetrieval', () => { expect(within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip')).not.toBeInTheDocument() }) + it('should save the default rerank model when editing retrieval with existing knowledge', async () => { + const user = userEvent.setup() + renderKnowledgeRetrieval({ + showConfigSnapshot: true, + initialDraft: { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Search Docs', + datasetRefs: [ + { + id: 'dataset-1', + name: 'Product Docs', + description: 'Docs corpus', + }, + ], + multipleRetrievalConfig: { + top_k: 4, + score_threshold: null, + reranking_enable: false, + }, + }, + ], + }, + }) + + await user.click(screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"Search Docs"}', + })) + + const dialog = screen.getByRole('dialog', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', + }) + await user.click(within(dialog).getByRole('button', { + name: 'Search Docs', + })) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', + }) + await user.clear(nameInput) + await user.type(nameInput, 'Default Rerank Docs') + + await waitFor(() => { + const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}') + expect(knowledgeConfig.sets[0].retrieval).toEqual(expect.objectContaining({ + mode: 'multiple', + reranking_enable: true, + reranking_mode: RerankingModeEnum.RerankingModel, + reranking_model: { + provider: 'rerank-provider', + model: 'rerank-model', + }, + })) + }) + }) + it('should save edited retrieval data into the config snapshot', async () => { const user = userEvent.setup() renderKnowledgeRetrieval({ showConfigSnapshot: true }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx index d809521336b..497040367c2 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx @@ -19,9 +19,11 @@ import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { Textarea } from '@langgenius/dify-ui/textarea' import { intersectionBy } from 'es-toolkit/compat' import { useAtomValue } from 'jotai' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { IndexingType } from '@/app/components/datasets/create/step-two/hooks/use-indexing-config' +import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import Field from '@/app/components/workflow/nodes/_base/components/field' import AddKnowledge from '@/app/components/workflow/nodes/knowledge-retrieval/components/add-dataset' import DatasetList from '@/app/components/workflow/nodes/knowledge-retrieval/components/dataset-list' @@ -33,6 +35,7 @@ import { MetadataFilteringVariableType, MetadataFilteringModeEnum as WorkflowMetadataFilteringModeEnum, } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { getMultipleRetrievalConfig } from '@/app/components/workflow/nodes/knowledge-retrieval/utils' import { DATASET_DEFAULT } from '@/config' import { useDocLink } from '@/context/i18n' import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation' @@ -254,6 +257,42 @@ export function AgentKnowledgeRetrievalDialog({ selectedDatasets, singleRetrievalConfig, } = dialogState + const { + modelList: rerankModelList, + defaultModel: rerankDefaultModel, + } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) + const { + currentModel: currentRerankModel, + currentProvider: currentRerankProvider, + } = useCurrentProviderAndModel( + rerankModelList, + rerankDefaultModel + ? { + ...rerankDefaultModel, + provider: rerankDefaultModel.provider.provider, + } + : undefined, + ) + const fallbackRerankModel = useMemo(() => ({ + provider: currentRerankProvider?.provider, + model: currentRerankModel?.model, + }), [currentRerankModel?.model, currentRerankProvider?.provider]) + const resolveMultipleRetrievalConfig = useCallback(( + config: MultipleRetrievalConfig, + nextSelectedDatasets = selectedDatasets, + originalDatasets = selectedDatasets, + ) => getMultipleRetrievalConfig( + config, + nextSelectedDatasets, + originalDatasets, + fallbackRerankModel, + ), [fallbackRerankModel, selectedDatasets]) + const effectiveMultipleRetrievalConfig = useMemo(() => { + if (retrievalMode !== RETRIEVE_TYPE.multiWay || selectedDatasets.length === 0) + return multipleRetrievalConfig + + return resolveMultipleRetrievalConfig(multipleRetrievalConfig) + }, [multipleRetrievalConfig, resolveMultipleRetrievalConfig, retrievalMode, selectedDatasets.length]) const patchDialogState = (patch: Partial) => { setDialogState(current => ({ ...current, @@ -303,7 +342,7 @@ export function AgentKnowledgeRetrievalDialog({ customQuery, selectedDatasets, retrievalMode, - multipleRetrievalConfig, + multipleRetrievalConfig: effectiveMultipleRetrievalConfig, singleRetrievalConfig, metadataFilterMode, metadataFilteringConditions, @@ -321,7 +360,7 @@ export function AgentKnowledgeRetrievalDialog({ customQuery, selectedDatasets, retrievalMode, - multipleRetrievalConfig, + multipleRetrievalConfig: effectiveMultipleRetrievalConfig, singleRetrievalConfig, metadataFilterMode, metadataFilteringConditions, @@ -330,15 +369,29 @@ export function AgentKnowledgeRetrievalDialog({ }) } const handleSelectedDatasetsChange = (nextDatasets: DataSet[]) => { - patchDialogState({ selectedDatasets: nextDatasets }) + const nextMultipleRetrievalConfig = retrievalMode === RETRIEVE_TYPE.multiWay && nextDatasets.length > 0 + ? resolveMultipleRetrievalConfig(multipleRetrievalConfig, nextDatasets) + : multipleRetrievalConfig + + patchDialogState({ + multipleRetrievalConfig: nextMultipleRetrievalConfig, + selectedDatasets: nextDatasets, + }) if (item) { - updateItem({ selectedDatasets: nextDatasets }) + updateItem({ + multipleRetrievalConfig: nextMultipleRetrievalConfig, + selectedDatasets: nextDatasets, + }) return } - if (nextDatasets.length > 0) - onItemCreate?.(createItemFromDialogState({ selectedDatasets: nextDatasets })) + if (nextDatasets.length > 0) { + onItemCreate?.(createItemFromDialogState({ + multipleRetrievalConfig: nextMultipleRetrievalConfig, + selectedDatasets: nextDatasets, + })) + } } const metadataList = useMemo(() => { const datasetsWithMetadata = selectedDatasets.filter(dataset => !!dataset.doc_metadata) @@ -354,7 +407,9 @@ export function AgentKnowledgeRetrievalDialog({ const datasetsError = getValidationMessage(itemValidation?.datasets) const queryError = getValidationMessage(itemValidation?.query) const retrievalError = getValidationMessage(itemValidation?.retrieval) - const metadataError = getValidationMessage(itemValidation?.metadata) + const metadataError = itemValidation?.metadata === 'metadata_model_required' + ? undefined + : getValidationMessage(itemValidation?.metadata) useEffect(() => { if (hydratedKey !== hydrationKey) { @@ -500,16 +555,28 @@ export function AgentKnowledgeRetrievalDialog({ { - patchDialogState({ retrievalMode: nextRetrievalMode }) - updateItem({ retrievalMode: nextRetrievalMode }) + const nextMultipleRetrievalConfig = nextRetrievalMode === RETRIEVE_TYPE.multiWay + ? resolveMultipleRetrievalConfig(multipleRetrievalConfig) + : multipleRetrievalConfig + + patchDialogState({ + multipleRetrievalConfig: nextMultipleRetrievalConfig, + retrievalMode: nextRetrievalMode, + }) + updateItem({ + multipleRetrievalConfig: nextMultipleRetrievalConfig, + retrievalMode: nextRetrievalMode, + }) }} onMultipleRetrievalConfigChange={(nextMultipleRetrievalConfig) => { - patchDialogState({ multipleRetrievalConfig: nextMultipleRetrievalConfig }) - updateItem({ multipleRetrievalConfig: nextMultipleRetrievalConfig }) + const normalizedMultipleRetrievalConfig = resolveMultipleRetrievalConfig(nextMultipleRetrievalConfig) + + patchDialogState({ multipleRetrievalConfig: normalizedMultipleRetrievalConfig }) + updateItem({ multipleRetrievalConfig: normalizedMultipleRetrievalConfig }) }} singleRetrievalModelConfig={singleRetrievalConfig?.model} onSingleRetrievalModelChange={(model) => { @@ -556,14 +623,16 @@ export function AgentKnowledgeRetrievalDialog({ )} > <> - + {selectedDatasets.length > 0 && ( + + )} {(datasetsError || retrievalError) && (
{datasetsError ?? retrievalError} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx index 1fd3b4d5508..c0feb0ed44f 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/index.tsx @@ -11,6 +11,7 @@ import { ConfigureSectionAddButton } from '../common/add-button' import { ConfigureSectionConfigurableItem } from '../common/configurable-item' import { ConfigureSectionEmpty } from '../common/empty' import { ConfigureSection } from '../common/section' +import { AgentConfigureTipContent } from '../common/tip-content' import { AgentKnowledgeRetrievalDialog } from './dialog' function KnowledgeRetrievalIcon() { @@ -87,7 +88,7 @@ export function AgentKnowledgeRetrieval() { label={t('agentDetail.configure.knowledgeRetrieval.label')} labelId="agent-configure-knowledge-retrieval-label" panelId={retrievalListId} - tip={knowledgeRetrievalTip} + tip={} tipAriaLabel={knowledgeRetrievalTip} rootClassName="border-b border-divider-subtle pt-4" panelContentClassName="flex flex-col gap-1 pb-4" diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx index 3a937a9bc66..88005d68c5f 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx @@ -7,7 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too import { useTranslation } from 'react-i18next' import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' -import { isAgentCompatibleModel } from '../../../model-compatibility' +import { isAgentCompatibleModel, isAgentSuggestedModel } from '../../../model-compatibility' import { useAgentOrchestrateReadOnly } from '../read-only-context' type AgentModelFieldProps = { @@ -48,6 +48,7 @@ export function AgentModelField({ providerSettingsSource="agent" showModelMeta={false} modelPredicate={isAgentCompatibleModel} + modelSuggestionPredicate={isAgentSuggestedModel} onSelect={onSelect} />
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx index e526dc8e015..9ce15bed43c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/publish-bar/index.tsx @@ -12,9 +12,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation' import { hasAgentComposerUnpublishedChangesAtom, isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store' -import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import useTimestamp from '@/hooks/use-timestamp' import { consoleQuery } from '@/service/client' @@ -109,9 +107,6 @@ export function AgentConfigurePublishBar({ const stableActiveConfigIsPublished = activeConfigIsPublished ?? (lastKnownPublishedRef.current ? true : undefined) const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom) const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom) - const knowledgeRetrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom) - const knowledgeValidation = validateKnowledgeRetrievals(knowledgeRetrievals) - const getValidationMessage = useKnowledgeValidationMessage() const publishableState = getPublishState({ activeConfigIsPublished: stableActiveConfigIsPublished, activeConfigSnapshot, @@ -127,7 +122,6 @@ export function AgentConfigurePublishBar({ isPublishing, }) const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished') - const publishValidationMessage = getValidationMessage(knowledgeValidation.firstIssue?.code) const workflowReferencesQueryOptions = consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ input: { params: { @@ -140,7 +134,7 @@ export function AgentConfigurePublishBar({ enabled: publishIsAvailable && !selectedVersionSnapshot, }) const restoreVersionMutation = useMutation(consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions()) - const canPublish = publishIsAvailable && knowledgeValidation.isValid + const canPublish = publishIsAvailable const handleRestoreVersion = (versionId: string) => { if (restoreVersionMutation.isPending) @@ -284,9 +278,6 @@ export function AgentConfigurePublishBar({ const currentStateMeta = stateMeta[publishState] const isConfirmingImpact = publishBarMode.status === 'confirmingImpact' && (canPublish || isPublishing) const impactReferences = publishBarMode.status === 'confirmingImpact' ? publishBarMode.references : [] - const effectiveMetaLabel = publishValidationMessage && publishIsAvailable - ? publishValidationMessage - : currentStateMeta.metaLabel return ( { expect(toast.success).toHaveBeenCalled() }) + it('should not show the frontend fallback error when skill upload fails', async () => { + const user = userEvent.setup() + mocks.uploadSkillMutationFn.mockRejectedValueOnce(new Error('Backend upload error')) + renderAgentSkills({ initialDraft: defaultAgentSoulConfigFormState }) + + await user.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i })) + + const input = await waitFor(() => { + const element = document.querySelector('input[type="file"]') + expect(element).not.toBeNull() + return element as HTMLInputElement + }) + const file = new File(['skill'], 'invoice-helper.skill', { type: 'application/zip' }) + await user.upload(input, file) + await user.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.upload\.action/i })) + + await waitFor(() => { + expect(mocks.uploadSkillMutationFn).toHaveBeenCalled() + }) + + expect(toast.error).not.toHaveBeenCalledWith('agentV2.agentDetail.configure.skills.upload.failed') + }) + it('should use workflow config skill endpoints with node_id for uploads and skill member queries', async () => { const user = userEvent.setup() renderAgentSkills({ diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx index bd64cca23b8..36be64434e9 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' +import { cn } from '@langgenius/dify-ui/cn' import { DialogCloseButton, DialogContent, @@ -26,6 +27,11 @@ type AgentSkillDetailSection = { export type AgentSkillDetail = { description: string fileCount?: number + fileListHeader?: ReactNode + fileListLoading?: boolean + fileListPanelClassName?: string + fileListTreeClassName?: string + fileListTreeListClassName?: string fileListTitle?: string files: AgentSkillFileNode[] folderOpenState?: (context: { file: AgentSkillFileNode, depth: number }) => boolean @@ -41,6 +47,7 @@ export type AgentSkillDetail = { isLoading?: boolean } onFolderOpenChange?: (context: { file: AgentSkillFileNode, depth: number, open: boolean }) => void + onFolderDoubleClick?: (context: { file: AgentSkillFileNode, depth: number }) => void onSelectFile?: (file: AgentSkillFileNode) => void renderFolderSuffix?: (context: { file: AgentSkillFileNode, depth: number }) => ReactNode selectedFileId?: string @@ -50,48 +57,75 @@ export type AgentSkillDetail = { const keepSkillFoldersClosed = () => false function AgentSkillFileList({ + fileListHeader, + fileListLoading, + fileListTreeClassName, + fileListTreeListClassName, fileListTitle, files, folderOpenState, onFolderOpenChange, + onFolderDoubleClick, onSelectFile, renderFolderSuffix, selectedFileId, }: { + fileListHeader?: ReactNode + fileListLoading?: boolean + fileListTreeClassName?: string + fileListTreeListClassName?: string fileListTitle?: string files: AgentSkillFileNode[] folderOpenState?: AgentSkillDetail['folderOpenState'] onFolderOpenChange?: AgentSkillDetail['onFolderOpenChange'] + onFolderDoubleClick?: AgentSkillDetail['onFolderDoubleClick'] onSelectFile?: (file: AgentSkillFileNode) => void renderFolderSuffix?: AgentSkillDetail['renderFolderSuffix'] selectedFileId?: string }) { const { t } = useTranslation('agentV2') + if (fileListLoading) { + return ( +
+ {fileListHeader ?? ( +

+ {fileListTitle ?? t('agentDetail.configure.skills.detail.files')} +

+ )} +
+ +
+
+ ) + } + return ( ( - onSelectFile(file)}> + ? ({ depth, file, selected, children }) => ( + onSelectFile(file)}> {children} ) : undefined} renderFolderSuffix={renderFolderSuffix} header={( - <> + fileListHeader ?? (

{fileListTitle ?? t('agentDetail.configure.skills.detail.files')}

- + ) )} /> ) @@ -239,7 +273,7 @@ export function AgentSkillDetailDialog({ return ( -
+
{detail.description} @@ -248,10 +282,15 @@ export function AgentSkillDetailDialog({
- + {skill.name}
-
- {file.name} +
+ {file.name}
{t('agentDetail.configure.skills.upload.fileType')} · @@ -212,9 +212,6 @@ export function AgentSkillUploadDialog({ setFile(undefined) onOpenChange(false) }, - onError: () => { - toast.error(t('agentDetail.configure.skills.upload.failed')) - }, } if (apiContext.workflow) { diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/hooks.spec.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/hooks.spec.ts index 5afbb2b2771..e46a028ff27 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/hooks.spec.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/__tests__/hooks.spec.ts @@ -29,6 +29,14 @@ const unauthorizedCredentialTool = { credentialRequired: true, } satisfies AgentProviderToolDefaultValue +const unauthorizedOAuthTool = { + ...unauthorizedCredentialTool, + provider_id: 'slack', + provider_name: 'slack', + provider_show_name: 'Slack', + credentialType: 'oauth2', +} satisfies AgentProviderToolDefaultValue + describe('addProviderTools', () => { it('should not mark tools that do not need credentials as unauthorized', () => { const nextTools = addProviderTools([], [noCredentialTool]) @@ -53,4 +61,16 @@ describe('addProviderTools', () => { }), ]) }) + + it('should preserve oauth credential type for credential-required OAuth tools', () => { + const nextTools = addProviderTools([], [unauthorizedOAuthTool]) + + expect(nextTools).toEqual([ + expect.objectContaining({ + credentialId: undefined, + credentialType: 'oauth2', + credentialVariant: 'unauthorized', + }), + ]) + }) }) 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 74ff5f7aab4..99bb919b7e7 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 @@ -1,3 +1,4 @@ +import type { AddOAuthButtonProps } from '@/app/components/plugins/plugin-auth/types' import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -37,6 +38,23 @@ vi.mock('@/app/components/workflow/block-icon', () => ({ ), })) +vi.mock('@/app/components/plugins/plugin-auth/authorize/add-oauth-button', () => ({ + default: ({ buttonText, onUpdate, renderTrigger }: AddOAuthButtonProps) => { + if (renderTrigger) { + return renderTrigger({ + isConfigured: false, + onClick: () => onUpdate?.(), + }) + } + + return ( + + ) + }, +})) + vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({ default: ({ formSchemas }: { formSchemas: Array<{ label?: Record, variable?: string }> }) => (
@@ -52,6 +70,7 @@ vi.mock('@/service/use-tools', () => ({ useAllCustomTools: () => ({ data: [] }), useAllWorkflowTools: () => ({ data: [] }), useAllMCPTools: () => ({ data: [] }), + useInvalidToolsByType: () => vi.fn(), })) const agentToolsDraft = { @@ -130,6 +149,28 @@ const reflectedUnauthorizedNoCredentialDraft = { ], } satisfies AgentSoulConfigFormState +const reflectedUnauthorizedOAuthCredentialTypeDraft = { + ...defaultAgentSoulConfigFormState, + tools: [ + { + id: 'google', + kind: 'provider', + name: 'google', + iconClassName: 'i-custom-public-other-default-tool-icon', + credentialType: 'unauthorized', + credentialVariant: 'none', + actions: [ + { + id: 'google-search', + name: 'search', + toolName: 'search', + description: '', + }, + ], + }, + ], +} satisfies AgentSoulConfigFormState + const googleProvider = { id: 'google', name: 'google', @@ -393,7 +434,10 @@ describe('AgentTools', () => { describe('Display Metadata', () => { it('should enrich reflected provider tools with provider icon and localized names', async () => { const user = userEvent.setup() - toolProviderState.builtInTools = [googleProvider] + toolProviderState.builtInTools = [{ + ...googleProvider, + allow_delete: false, + }] renderAgentTools(reflectedAgentToolsDraft) expect(screen.getByRole('button', { @@ -433,6 +477,21 @@ describe('AgentTools', () => { expect(store.get(isAgentComposerDirtyAtom)).toBe(false) }) + it('should show authorization action for reflected OAuth provider tools with unauthorized credential type', () => { + toolProviderState.builtInTools = [{ + ...googleProvider, + allow_delete: true, + is_team_authorization: false, + team_credentials: {}, + }] + renderAgentTools(reflectedUnauthorizedOAuthCredentialTypeDraft) + + expect(screen.getByRole('button', { + name: 'tools.notAuthorized', + })).toBeInTheDocument() + expect(screen.queryByText('plugin.auth.setupOAuth')).not.toBeInTheDocument() + }) + it('should open provider tool settings with catalog icon and parameters', async () => { const user = userEvent.setup() toolProviderState.builtInTools = [duckDuckGoProvider] diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts index 9be5cebfc91..9b11f711d02 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/hooks.ts @@ -40,6 +40,9 @@ const getCredentialType = (tool: AgentProviderToolDefaultValue) => { if (!tool.credentialRequired) return undefined + if (tool.credentialType === 'oauth2') + return 'oauth2' as const + if (!tool.allowDelete) return tool.credential_id ? 'api-key' as const : 'unauthorized' as const @@ -102,26 +105,10 @@ export function useAgentToolsOperations() { const [tools, setTools] = useAtom(agentComposerToolsAtom) const removeProviderTool = useRemoveProviderTool() const removeProviderToolAction = useRemoveProviderToolAction() - const [expandedToolIds, setExpandedToolIds] = useState>(() => new Set()) const [settingTarget, setSettingTarget] = useState(null) const [isCliToolDialogOpen, setIsCliToolDialogOpen] = useState(false) const [editingCliTool, setEditingCliTool] = useState(null) - const setToolOpen = useCallback((tool: AgentTool, open: boolean) => { - if (tool.kind === 'cli') - return - - setExpandedToolIds((currentIds) => { - const nextIds = new Set(currentIds) - if (open) - nextIds.add(tool.id) - else - nextIds.delete(tool.id) - - return nextIds - }) - }, []) - const addTools = useCallback((selectedTools: AgentProviderToolDefaultValue[]) => { setTools(addProviderTools(tools, selectedTools)) }, [setTools, tools]) @@ -168,11 +155,6 @@ export function useAgentToolsOperations() { }, []) const deleteProviderTool = useCallback((toolId: string) => { - setExpandedToolIds((currentIds) => { - const nextIds = new Set(currentIds) - nextIds.delete(toolId) - return nextIds - }) closeSettingTargetIfRemoved(toolId) removeProviderTool(toolId) }, [closeSettingTargetIfRemoved, removeProviderTool]) @@ -191,12 +173,10 @@ export function useAgentToolsOperations() { return { tools, selectedTools, - expandedToolIds, settingTarget, isCliToolDialogOpen, editingCliTool, setTools, - setToolOpen, setSettingTarget, addTools, deleteCliTool, 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 20cbb247888..f17a931b2f8 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 @@ -7,8 +7,9 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import type { AgentCliTool, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { useCallback, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { CollectionType } from '@/app/components/tools/types' import { ToolPickerContent } from '@/app/components/workflow/block-selector/tool-picker' import { useGetLanguage } from '@/context/i18n' import { useSetProviderToolCredential } from '@/features/agent-v2/agent-composer/store-modules/tools' @@ -31,10 +32,8 @@ import { useAgentToolsOperations } from './hooks' import { ProviderToolSettingsDialog } from './provider-tool/dialog' import { AgentProviderToolItem } from './provider-tool/item' -function AgentToolItem({ +const AgentToolItem = memo(({ tool, - isExpanded, - onOpenChange, onConfigureAction, onDeleteCliTool, onDeleteProviderTool, @@ -43,18 +42,14 @@ function AgentToolItem({ onCredentialChange, }: { tool: AgentTool - isExpanded: boolean - onOpenChange: (tool: AgentTool, open: boolean) => void onConfigureAction: (target: ToolSettingTarget) => void onDeleteCliTool: (toolId: string) => void onDeleteProviderTool: (toolId: string) => void onDeleteProviderToolAction: (toolId: string, actionId: string) => void onEditCliTool: (tool: AgentCliTool) => void - onCredentialChange: (toolId: string, credentialId?: string) => void -}) { - const handleOpenChange = useCallback((open: boolean) => { - onOpenChange(tool, open) - }, [onOpenChange, tool]) + onCredentialChange: (toolId: string, credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void +}) => { + const [isExpanded, setIsExpanded] = useState(false) const handleRemoveProvider = useCallback(() => { onDeleteProviderTool(tool.id) @@ -73,8 +68,8 @@ function AgentToolItem({ onEditCliTool(tool) }, [onEditCliTool, tool]) - const handleCredentialChange = useCallback((credentialId?: string) => { - onCredentialChange(tool.id, credentialId) + const handleCredentialChange = useCallback((credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => { + onCredentialChange(tool.id, credentialId, credentialType) }, [onCredentialChange, tool.id]) if (tool.kind === 'provider') { @@ -82,7 +77,7 @@ function AgentToolItem({ ) -} +}) function useAgentToolProviderMap() { const { data: buildInTools } = useAllBuiltInTools() @@ -139,8 +134,44 @@ function getLocalizedText( return text?.[language] ?? text?.en_US ?? text?.zh_Hans } -function getProviderCredentialRequired(provider?: ToolWithProvider) { - return Object.keys(provider?.team_credentials ?? {}).length > 0 +function getProviderCredentialType(provider?: ToolWithProvider): AgentProviderTool['credentialType'] { + if (!provider) + return undefined + + if (Object.keys(provider.team_credentials ?? {}).length > 0) + return 'api-key' + + if (provider.type === CollectionType.builtIn && provider.allow_delete) + return 'oauth2' + + return undefined +} + +function getDisplayCredentialType( + tool: AgentProviderTool, + providerCredentialType: AgentProviderTool['credentialType'], +) { + if (!providerCredentialType) + return undefined + + if (providerCredentialType === 'oauth2' && tool.credentialType === 'unauthorized') + return 'oauth2' as const + + return tool.credentialType ?? providerCredentialType +} + +function getProviderCredentialVariant( + tool: AgentProviderTool, + provider: ToolWithProvider, + providerCredentialType: AgentProviderTool['credentialType'], +) { + if (!providerCredentialType) + return 'none' as const + + if (tool.credentialVariant !== 'none') + return tool.credentialVariant + + return tool.credentialId || provider.is_team_authorization ? 'authorized' as const : 'unauthorized' as const } function useDisplayTools( @@ -161,7 +192,7 @@ function useDisplayTools( return tool const providerToolByName = new Map(provider.tools.map(providerTool => [providerTool.name, providerTool])) - const credentialRequired = getProviderCredentialRequired(provider) + const providerCredentialType = getProviderCredentialType(provider) return { ...tool, @@ -170,9 +201,11 @@ function useDisplayTools( iconDark: tool.iconDark ?? provider.icon_dark, providerType: tool.providerType ?? provider.type, allowDelete: tool.allowDelete ?? provider.allow_delete, - credentialKey: credentialRequired ? tool.credentialKey : undefined, - credentialType: credentialRequired ? tool.credentialType : undefined, - credentialVariant: credentialRequired ? tool.credentialVariant : 'none', + credentialKey: providerCredentialType + ? tool.credentialKey ?? 'agentDetail.configure.tools.credential.authOne' + : undefined, + credentialType: getDisplayCredentialType(tool, providerCredentialType), + credentialVariant: getProviderCredentialVariant(tool, provider, providerCredentialType), actions: tool.actions.map((action) => { const providerTool = providerToolByName.get(action.toolName) @@ -266,7 +299,10 @@ function AddToolMenu({ allowDelete: (providerById.get(tool.provider_id) ?? providerById.get(tool.provider_name) ?? (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined))?.allow_delete, - credentialRequired: getProviderCredentialRequired(providerById.get(tool.provider_id) + credentialType: getProviderCredentialType(providerById.get(tool.provider_id) + ?? providerById.get(tool.provider_name) + ?? (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined)), + credentialRequired: !!getProviderCredentialType(providerById.get(tool.provider_id) ?? providerById.get(tool.provider_name) ?? (tool.plugin_id ? providerById.get(tool.plugin_id) : undefined)), }), [providerById]) @@ -316,7 +352,7 @@ function AddToolMenu({ : ( void + onCredentialChange: (credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void }) { const { t } = useTranslation() const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) @@ -78,8 +79,29 @@ function UnauthorizedCredentialStatus({ }, []) const handleCredentialUpdate = useCallback(() => { invalidPluginCredentialInfo() - onCredentialChange() - }, [invalidPluginCredentialInfo, onCredentialChange]) + onCredentialChange(undefined, tool.credentialType) + }, [invalidPluginCredentialInfo, onCredentialChange, tool.credentialType]) + + if (tool.credentialType === 'oauth2') { + return ( + ( + + )} + /> + ) + } return ( <> @@ -108,16 +130,16 @@ function CredentialStatus({ onCredentialChange, }: { tool: AgentProviderTool - onCredentialChange: (credentialId?: string) => void + onCredentialChange: (credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void }) { const canSwitchCredential = (tool.providerType ?? CollectionType.builtIn) === CollectionType.builtIn && tool.allowDelete const handleAuthorizationItemClick = useCallback((id: string) => { - onCredentialChange(id === '__workspace_default__' ? undefined : id || undefined) - }, [onCredentialChange]) + onCredentialChange(id === '__workspace_default__' ? undefined : id || undefined, tool.credentialType) + }, [onCredentialChange, tool.credentialType]) const handleDefaultCredentialChange = useCallback((id?: string) => { if (!tool.credentialId && id) - onCredentialChange(id) - }, [onCredentialChange, tool.credentialId]) + onCredentialChange(id, tool.credentialType) + }, [onCredentialChange, tool.credentialId, tool.credentialType]) if (tool.credentialVariant === 'none') return null @@ -150,7 +172,7 @@ function CredentialStatus({ ) } -function ProviderToolActionItem({ +const ProviderToolActionItem = memo(({ action, tool, onConfigureAction, @@ -160,7 +182,7 @@ function ProviderToolActionItem({ tool: AgentProviderTool onConfigureAction: (target: ToolSettingTarget) => void onRemoveAction: (actionId: string) => void -}) { +}) => { const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() const handleConfigureAction = useCallback(() => { @@ -200,9 +222,9 @@ function ProviderToolActionItem({ )}
) -} +}) -export function AgentProviderToolItem({ +export const AgentProviderToolItem = memo(({ tool, isExpanded, onOpenChange, @@ -217,8 +239,8 @@ export function AgentProviderToolItem({ onConfigureAction: (target: ToolSettingTarget) => void onRemoveAction: (actionId: string) => void onRemoveProvider: () => void - onCredentialChange: (credentialId?: string) => void -}) { + onCredentialChange: (credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => void +}) => { const { t } = useTranslation('agentV2') const readOnly = useAgentOrchestrateReadOnly() const { theme } = useTheme() @@ -275,18 +297,20 @@ export function AgentProviderToolItem({
-
- {tool.actions.map(action => ( - - ))} -
+ {isExpanded && ( +
+ {tool.actions.map(action => ( + + ))} +
+ )}
) -} +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/types.ts b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/types.ts index 4401994fa37..e977496bcf1 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/types.ts +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/tools/types.ts @@ -6,6 +6,7 @@ import type { export type AgentProviderToolDefaultValue = ToolDefaultValue & { allowDelete?: boolean + credentialType?: AgentProviderTool['credentialType'] credentialRequired?: boolean } 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 c626e2fc813..f6fbcb61e31 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,4 +1,4 @@ -import type { ComponentProps } from 'react' +import type { ComponentProps, ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' @@ -29,11 +29,23 @@ vi.mock('@/next/dynamic', async () => { default: () => function MockChat(props: { onSend: (message: string) => unknown onStopResponding: () => void + sendButtonLabel?: string + sendButtonLoading?: boolean + showPromptLog?: boolean + footerNotice?: string + chatNode?: ReactNode }) { const [sent, setSent] = useState(false) return ( -
+
+ {props.chatNode} {`sessionSent:${sent ? 'yes' : 'no'}`} - {mode === 'build' && ( + {mode === 'build' && showWorkingDirectoryAction && ( )}
diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-breadcrumb.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-breadcrumb.tsx new file mode 100644 index 00000000000..45395018258 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-breadcrumb.tsx @@ -0,0 +1,204 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { useTranslation } from 'react-i18next' + +const AGENT_WORKING_DIRECTORY_HOME_PATH = '~' +const AGENT_WORKING_DIRECTORY_ROOT_PATH = '.' + +export type AgentWorkingDirectoryPath + = | typeof AGENT_WORKING_DIRECTORY_HOME_PATH + | typeof AGENT_WORKING_DIRECTORY_ROOT_PATH + | string + +type AgentWorkingDirectoryBreadcrumbItemData = { + iconClassName: string + label: string + path: AgentWorkingDirectoryPath +} + +const normalizeWorkingDirectoryPath = (path: AgentWorkingDirectoryPath) => { + if (path === AGENT_WORKING_DIRECTORY_ROOT_PATH || path === AGENT_WORKING_DIRECTORY_HOME_PATH) + return path + + if (path.startsWith('~/')) + return `${AGENT_WORKING_DIRECTORY_HOME_PATH}/${path.slice(2).replace(/^\/+|\/+$/g, '')}` + + return path.replace(/^\.\/+/, '').replace(/^\/+|\/+$/g, '') +} + +function buildPathFromSegments(segments: string[], options: { startsFromHome: boolean }) { + if (options.startsFromHome) + return segments.length ? `${AGENT_WORKING_DIRECTORY_HOME_PATH}/${segments.join('/')}` : AGENT_WORKING_DIRECTORY_HOME_PATH + + return segments.length ? segments.join('/') : AGENT_WORKING_DIRECTORY_ROOT_PATH +} + +function getBreadcrumbItems({ + homeLabel, + path, +}: { + homeLabel: string + path: AgentWorkingDirectoryPath +}): AgentWorkingDirectoryBreadcrumbItemData[] { + const normalizedPath = normalizeWorkingDirectoryPath(path) + const normalizedHomeLabel = homeLabel === 'home' ? 'Home' : homeLabel + + if (normalizedPath === AGENT_WORKING_DIRECTORY_HOME_PATH) { + return [{ + iconClassName: 'i-ri-folder-3-line', + label: normalizedHomeLabel, + path: AGENT_WORKING_DIRECTORY_HOME_PATH, + }] + } + + if (normalizedPath === AGENT_WORKING_DIRECTORY_ROOT_PATH) { + return [{ + iconClassName: 'i-ri-folder-3-line', + label: AGENT_WORKING_DIRECTORY_ROOT_PATH, + path: AGENT_WORKING_DIRECTORY_ROOT_PATH, + }] + } + + const startsFromHome = normalizedPath.startsWith(`${AGENT_WORKING_DIRECTORY_HOME_PATH}/`) + const segments = startsFromHome + ? normalizedPath.slice(2).split('/').filter(Boolean) + : normalizedPath.split('/').filter(Boolean) + + const rootItem: AgentWorkingDirectoryBreadcrumbItemData = { + iconClassName: 'i-ri-folder-3-line', + label: startsFromHome ? normalizedHomeLabel : segments[0]!, + path: buildPathFromSegments(startsFromHome ? [] : segments.slice(0, 1), { startsFromHome }), + } + + return [ + rootItem, + ...segments.slice(startsFromHome ? 0 : 1).map((segment, index) => { + const pathSegments = startsFromHome + ? segments.slice(0, index + 1) + : segments.slice(0, index + 2) + + return { + iconClassName: 'i-ri-folder-3-line', + label: segment, + path: buildPathFromSegments(pathSegments, { startsFromHome }), + } + }), + ] +} + +function getVisibleBreadcrumbItems(items: AgentWorkingDirectoryBreadcrumbItemData[]) { + if (items.length <= 3) { + return { + hiddenItems: [], + visibleItems: items, + } + } + + return { + hiddenItems: items.slice(1, -2), + visibleItems: [items[0]!, ...items.slice(-2)], + } +} + +function AgentWorkingDirectoryBreadcrumbItem({ + active, + iconClassName, + label, + onClick, +}: { + active: boolean + iconClassName: string + label: string + onClick: () => void +}) { + return ( + + ) +} + +export function AgentWorkingDirectoryBreadcrumb({ + path, + onPathChange, +}: { + path: AgentWorkingDirectoryPath + onPathChange: (path: AgentWorkingDirectoryPath) => void +}) { + const { t } = useTranslation('agentV2') + const items = getBreadcrumbItems({ + homeLabel: t('agentDetail.configure.workingDirectory.home'), + path, + }) + const { hiddenItems, visibleItems } = getVisibleBreadcrumbItems(items) + + const renderSeparator = (key: string) => ( + / + ) + + return ( +
+ +
+ ) +} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx index 7814769e735..7e327e9c276 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/working-directory-panel.tsx @@ -1,14 +1,17 @@ 'use client' import type { SandboxFileEntryResponse, SandboxListResponse, SandboxReadResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { AgentWorkingDirectoryPath } from './working-directory-breadcrumb' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' import { Dialog } from '@langgenius/dify-ui/dialog' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { skipToken, useQueries, useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' import { getFileIconType } from '../orchestrate/files/file-icon' import { AgentSkillDetailDialog } from '../orchestrate/skills/detail-dialog' +import { AgentWorkingDirectoryBreadcrumb } from './working-directory-breadcrumb' type AgentWorkingDirectoryPanelProps = { source: AgentWorkingDirectorySource @@ -33,16 +36,26 @@ type SandboxErrorPayload = { } const normalizeSandboxPath = (path: string) => { - const normalizedPath = path.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '') + const normalizedPath = path.replace(/^~(?:\/|$)/, '').replace(/^\.\//, '').replace(/^\/+|\/+$/g, '') return normalizedPath === '.' ? '' : normalizedPath } +const toSandboxHomePath = (path: string) => { + if (path === '.') + return '.' + + const normalizedPath = normalizeSandboxPath(path) + return normalizedPath ? `~/${normalizedPath}` : '~' +} + +const toSandboxApiPath = toSandboxHomePath + const joinSandboxPath = (basePath: string, name: string) => { const normalizedBasePath = normalizeSandboxPath(basePath) return normalizedBasePath ? `${normalizedBasePath}/${name}` : name } -function getSandboxEntryPathSegments(entryName: string, basePath: string) { +function getSandboxEntryRelativePathSegments(entryName: string, basePath: string) { const normalizedBasePath = normalizeSandboxPath(basePath) const normalizedEntryName = normalizeSandboxPath(entryName) @@ -52,22 +65,59 @@ function getSandboxEntryPathSegments(entryName: string, basePath: string) { if (!normalizedBasePath) return normalizedEntryName.split('/').filter(Boolean) - if (normalizedEntryName === normalizedBasePath || normalizedEntryName.startsWith(`${normalizedBasePath}/`)) - return normalizedEntryName.split('/').filter(Boolean) + if (normalizedEntryName === normalizedBasePath) + return [] - return [...normalizedBasePath.split('/').filter(Boolean), ...normalizedEntryName.split('/').filter(Boolean)] + if (normalizedEntryName.startsWith(`${normalizedBasePath}/`)) + return normalizedEntryName.slice(normalizedBasePath.length + 1).split('/').filter(Boolean) + + return normalizedEntryName.split('/').filter(Boolean) } -function buildSandboxFileTree(entries: SandboxFileEntryResponse[] = [], basePath = '.'): AgentFileNode[] { +function buildSandboxFileTree( + entries: SandboxFileEntryResponse[] = [], + basePath = '.', + options: { nestRootPath?: string, nestUnderBasePath?: boolean } = {}, +): AgentFileNode[] { + const normalizedBasePath = normalizeSandboxPath(basePath) + const normalizedNestRootPath = normalizeSandboxPath(options.nestRootPath ?? '.') const rootFiles: AgentFileNode[] = [] + let baseFolder: AgentFileNode | undefined + + if (options.nestUnderBasePath && normalizedBasePath) { + let currentFiles = rootFiles + let currentPath = normalizedNestRootPath + const basePathSegments = normalizedBasePath.split('/').filter(Boolean) + const nestRootPathSegments = normalizedNestRootPath.split('/').filter(Boolean) + const nestedBasePathSegments = normalizedNestRootPath && ( + normalizedBasePath === normalizedNestRootPath + || normalizedBasePath.startsWith(`${normalizedNestRootPath}/`) + ) + ? basePathSegments.slice(nestRootPathSegments.length) + : basePathSegments + + nestedBasePathSegments.forEach((segment) => { + currentPath = joinSandboxPath(currentPath, segment) + const folder: AgentFileNode = { + id: currentPath, + name: segment, + icon: 'folder', + children: [], + } + + currentFiles.push(folder) + currentFiles = folder.children ?? [] + baseFolder = folder + }) + } for (const entry of entries) { - const pathSegments = getSandboxEntryPathSegments(entry.name, basePath) + const pathSegments = getSandboxEntryRelativePathSegments(entry.name, basePath) if (!pathSegments.length) continue - let currentFiles = rootFiles - let currentPath = '' + let currentFiles = baseFolder?.children ?? rootFiles + let currentPath = normalizedBasePath pathSegments.forEach((segment, index) => { const isLeaf = index === pathSegments.length - 1 @@ -169,6 +219,16 @@ async function isNoActiveSessionError(error: unknown) { const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404 +function isSandboxPathWithinDirectory(path: string, directory: string) { + const normalizedPath = normalizeSandboxPath(path) + const normalizedDirectory = normalizeSandboxPath(directory) + + if (!normalizedDirectory) + return true + + return normalizedPath === normalizedDirectory || normalizedPath.startsWith(`${normalizedDirectory}/`) +} + export function AgentWorkingDirectoryPanel({ source, onOpenChange, @@ -176,6 +236,7 @@ export function AgentWorkingDirectoryPanel({ }: AgentWorkingDirectoryPanelProps) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') + const [selectedDirectoryPath, setSelectedDirectoryPath] = useState() const [selectedFileId, setSelectedFileId] = useState() const [loadedFolderPaths, setLoadedFolderPaths] = useState([]) const [openFolderPaths, setOpenFolderPaths] = useState([]) @@ -186,16 +247,40 @@ export function AgentWorkingDirectoryPanel({ const hasWorkingDirectorySource = source.type === 'agent' ? !!source.conversationId : !!source.appId && !!workflowNodeRunId + const sandboxInfoQueryOptions = consoleQuery.agent.byAgentId.sandbox.get.queryOptions({ + input: source.type === 'agent' && source.conversationId + ? { + params: { + agent_id: source.agentId, + }, + query: { + conversation_id: source.conversationId, + }, + } + : skipToken, + context: { + silent: true, + }, + }) + const sandboxInfoQuery = useQuery({ + ...sandboxInfoQueryOptions, + enabled: open && source.type === 'agent' && !!source.conversationId, + retry: false, + }) + const isSandboxInfoLoading = source.type === 'agent' && !!source.conversationId && sandboxInfoQuery.isPending + const workspaceDirectoryPath = sandboxInfoQuery.data?.workspace_cwd + const directoryPath = selectedDirectoryPath ?? workspaceDirectoryPath ?? '.' + const showReturnToWorkspaceButton = !!workspaceDirectoryPath && !isSandboxPathWithinDirectory(directoryPath, workspaceDirectoryPath) const getFileListQueryOptions = (path: string) => source.type === 'agent' ? consoleQuery.agent.byAgentId.sandbox.files.get.queryOptions({ - input: source.conversationId + input: source.conversationId && !isSandboxInfoLoading ? { params: { agent_id: source.agentId, }, query: { conversation_id: source.conversationId, - path, + path: toSandboxApiPath(path), }, } : skipToken, @@ -212,7 +297,7 @@ export function AgentWorkingDirectoryPanel({ node_id: source.nodeId, }, query: { - path, + path: toSandboxApiPath(path), }, } : skipToken, @@ -220,7 +305,14 @@ export function AgentWorkingDirectoryPanel({ silent: true, }, }) - const fileListQueryOptions = getFileListQueryOptions('.') + const handleDirectoryPathChange = (path: AgentWorkingDirectoryPath) => { + setSelectedDirectoryPath(path) + setSelectedFileId(undefined) + setLoadedFolderPaths([]) + setOpenFolderPaths([]) + setPendingOpenFolderPaths([]) + } + const fileListQueryOptions = getFileListQueryOptions(directoryPath) const fileListQuery = useQuery({ ...fileListQueryOptions, queryFn: async (context): Promise => { @@ -267,12 +359,19 @@ export function AgentWorkingDirectoryPanel({ }) : [], }) - const workingDirectoryFiles = expandedFolderQueries.reduce((files, query) => { - return mergeSandboxFileTree(files, buildSandboxFileTree(query.data?.entries, query.data?.path)) + const workingDirectoryFiles = expandedFolderQueries.reduce((files, query, index) => { + return mergeSandboxFileTree(files, buildSandboxFileTree( + query.data?.entries, + loadedFolderPaths[index] ?? query.data?.path, + { + nestRootPath: directoryPath, + nestUnderBasePath: true, + }, + )) }, buildSandboxFileTree(fileListQuery.data?.entries, fileListQuery.data?.path)) const selectedWorkingDirectoryFile = findReadableFile(workingDirectoryFiles, selectedFileId) ?? findFirstReadableFile(workingDirectoryFiles) - const isFileListLoading = hasWorkingDirectorySource && fileListQuery.isPending + const isFileListLoading = hasWorkingDirectorySource && (isSandboxInfoLoading || fileListQuery.isPending) const loadingFolderPaths = new Set(loadedFolderPaths.filter((path, index) => expandedFolderQueries[index]?.isPending)) const loadedFolderPathIndexes = new Map(loadedFolderPaths.map((path, index) => [path, index])) const fileReadQueryOptions = source.type === 'agent' @@ -284,7 +383,7 @@ export function AgentWorkingDirectoryPanel({ }, query: { conversation_id: source.conversationId, - path: selectedWorkingDirectoryFile.id, + path: toSandboxApiPath(selectedWorkingDirectoryFile.id), }, } : skipToken, @@ -301,7 +400,7 @@ export function AgentWorkingDirectoryPanel({ node_id: source.nodeId, }, query: { - path: selectedWorkingDirectoryFile.id, + path: toSandboxApiPath(selectedWorkingDirectoryFile.id), }, } : skipToken, @@ -340,6 +439,43 @@ export function AgentWorkingDirectoryPanel({ detail={{ description: t('agentDetail.configure.workingDirectory.description'), fileCount: countReadableFiles(workingDirectoryFiles), + fileListHeader: isSandboxInfoLoading + ? ( +

+ {t('agentDetail.configure.workingDirectory.fileSystem')} +

+ ) + : ( +
+
+

+ {t('agentDetail.configure.workingDirectory.fileSystem')} +

+ {showReturnToWorkspaceButton && ( + + handleDirectoryPathChange(workspaceDirectoryPath)} + > + + + + {t('agentDetail.configure.workingDirectory.returnToWorkspace')} + + + )} +
+ +
+ ), + fileListLoading: isSandboxInfoLoading, + fileListPanelClassName: 'w-[360px]', + fileListTreeClassName: 'px-0', + fileListTreeListClassName: 'px-1', fileListTitle: t('agentDetail.configure.workingDirectory.title'), files: workingDirectoryFiles, filePreview: { @@ -371,6 +507,7 @@ export function AgentWorkingDirectoryPanel({ ? (paths.includes(file.id) ? paths : [...paths, file.id]) : paths.filter(path => path !== file.id)) }, + onFolderDoubleClick: ({ file }) => handleDirectoryPathChange(toSandboxHomePath(file.id)), onSelectFile: selectedFile => setSelectedFileId(selectedFile.id), renderFolderSuffix: ({ file }) => loadingFolderPaths.has(file.id) ? ( diff --git a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts index fece602ba37..e4fa261c80f 100644 --- a/web/features/agent-v2/agent-detail/configure/model-compatibility.ts +++ b/web/features/agent-v2/agent-detail/configure/model-compatibility.ts @@ -1,75 +1,99 @@ import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' -type ProviderModelCompatibility = { - providers: string[] - incompatibleModels: RegExp[] -} +const agentIncompatibleModelPatterns: RegExp[] = [ + // openai + /^chatgpt-/i, + /^gpt-5(?:$|-|\.1$|\.2$)/i, + /^gpt.*(?:min|chat)/i, + /^gpt.*nano/i, + /^gpt-4/i, + /^gpt-3/i, + /^o[134](?:-|$)/i, -const agentIncompatibleModelConfig: ProviderModelCompatibility[] = [ - { - providers: ['openai'], - incompatibleModels: [ - /^gpt-4o-mini(?:-|$)/i, - /^gpt-4\.1-(?:mini|nano)(?:-|$)/i, - /^gpt-4(?:-|$)/i, - /^gpt-3\.5/i, - /^o[34]-mini(?:-|$)/i, - ], - }, - { - providers: ['anthropic'], - incompatibleModels: [ - /^claude-3-(?:haiku|sonnet|opus)(?:-|$)/i, - /^claude-3(?:\.5|-5)-(?:haiku|sonnet)(?:-|$)/i, - ], - }, - { - providers: ['gemini', 'google'], - incompatibleModels: [ - /^gemini-2[.-][05]-flash(?:-lite)?(?:-|$)/i, - /^gemini-1[.-]5-flash(?:-8b)?(?:-|$)/i, - ], - }, - { - providers: ['deepseek'], - incompatibleModels: [ - /^deepseek-r1$/i, - /^deepseek-r1-lite$/i, - /^deepseek-r1-distill(?:-[a-z0-9]+)*-(?:1\.5b|7b|8b|14b|32b|70b)$/i, - ], - }, - { - providers: ['minimax'], - incompatibleModels: [ - /^minimax-text-01$/i, - /^minimax-m1$/i, - ], - }, - { - providers: ['tongyi', 'qwen'], - incompatibleModels: [ - /^qwen2[.-]5(?:-[a-z0-9.]+)?-instruct(?:-|$)/i, - /^qwen2[.-]5-coder(?:-|$)/i, - /^qwen3-(?:0\.6b|1\.7b|4b|8b|14b|30b)(?:-|$)/i, - ], - }, - { - providers: ['chatglm', 'zhipuai'], - incompatibleModels: [ - /^glm-4-(?:air|airx|flash)$/i, - /^glm-z1-(?:air|flash)$/i, - ], - }, + // anthropic + /^claude-3/i, + + // gemini + /^gemini.*flash[ .-]lite/i, + /^gemini[ .-]2(?![ .-]5[ .-]pro$)/i, + /^gemini[ .-]1[ .-]5[ .-]flash(?:[ .-]8b)?(?:[ .-]|$)/i, + /^Nano/i, + + // x + /^grok-code-/i, + /^grok.*beta/i, + /^grok-(?:2|3)/i, + + // deepseek + /^deepseek-(?:chat|coder|reasoner)(?:-|$)/i, + /^deepseek-r1$/i, + /^deepseek-r1-distill-/i, + /^deepseek-v3/i, + + // qwen + /^qvq-/i, + /^qwq-/i, + /^qwen-/i, + /^qwen2/i, + /^qwen3-/i, + /^qwen3\.5/i, + /^qwen.*flash/i, + /^farui-plus$/i, + + // zhipu ai + /^chatglm-(?:2|3)/i, + /^glm-3/i, + /^glm-4/i, + /^glm-z/i, + + // moonshot + /^kimi-k2-/i, + /^moonshot-v1/i, + + // minimax + /^abab/i, + /^minimax-text-01$/i, + /^minimax-m1$/i, + /^minimax-m2(?:-|$)/i, ] -const normalizeProviderName = (provider: string) => provider.split('/').at(-1)?.toLowerCase() ?? provider.toLowerCase() +const agentSuggestedModelPatterns: RegExp[] = [ + // openai + /^gpt[ .-]5\.5$/i, + /^gpt[ .-]5\.5[ .-]pro$/i, -export function isAgentCompatibleModel(provider: Model, modelItem: ModelItem) { - const providerName = normalizeProviderName(provider.provider) - const providerConfig = agentIncompatibleModelConfig.find(config => config.providers.includes(providerName)) + // anthropic + /^(?:claude[ .-])?opus[ .-]4\.8$/i, + /^(?:claude[ .-])?opus[ .-]4\.7$/i, + /^(?:claude[ .-])?sonnet[ .-]4\.6$/i, - if (!providerConfig) - return true + // gemini + /^gemini 3\.1 pro preview$/i, - return !providerConfig.incompatibleModels.some(pattern => pattern.test(modelItem.model)) + // x + /^grok[ .-]4\.3$/i, + + // deepseek + /^deepseek[ .-]v4[ .-]pro$/i, + + // qwen + /^qwen[ .-]?3\.7[ .-]max$/i, + /^qwen[ .-]?3[ .-]coder[ .-]plus$/i, + + // moonshot + /^kimi[ .-]k2\.6$/i, + + // minimax + /^minimax[ .-]m3$/i, + + // zhipuai + /^glm[ .-]5\.1$/i, +] + +export function isAgentCompatibleModel(_provider: Model, modelItem: ModelItem) { + return !agentIncompatibleModelPatterns.some(pattern => pattern.test(modelItem.label.en_US)) +} + +export function isAgentSuggestedModel(_provider: Model, modelItem: ModelItem) { + return agentSuggestedModelPatterns.some(pattern => pattern.test(modelItem.label.en_US)) } diff --git a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts index 900a960540e..85cb183204d 100644 --- a/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts +++ b/web/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft.ts @@ -1,8 +1,9 @@ 'use client' import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' -import type { AgentBuildDraftChangedKey } from './components/orchestrate/build-draft-changes-context' +import type { AgentBuildDraftChangedKey, AgentBuildDraftChangeItem, AgentBuildDraftChangeSummary } from './components/orchestrate/build-draft-changes-context' import type { AgentConfigureSoulSource } from './state' +import type { AgentFileNode, AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import isEqual from 'fast-deep-equal' @@ -13,9 +14,121 @@ import { consoleQuery } from '@/service/client' import { usePrepareAgentBuildDraftBeforeRun } from './use-agent-build-draft-run' const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404 +const BUILD_NOTE_FILE_ID = '__agent_config_build_note__' +const BUILD_NOTE_FILE_NAME = 'build_note.md' + const getAgentSoulConfigFromRefetchResult = (result: unknown) => { return (result as { data?: { agent_soul?: AgentSoulConfig } } | undefined)?.data?.agent_soul } +const flattenFileNodes = (files: AgentFileNode[]): AgentFileNode[] => files.flatMap(file => ( + file.children?.length ? [file, ...flattenFileNodes(file.children)] : [file] +)) + +function getItemDiff({ + currentItems, + nextItems, + getIcon, + getKey, + getName, +}: { + currentItems: readonly TItem[] + nextItems: readonly TItem[] + getIcon?: (item: TItem) => AgentBuildDraftChangeItem['icon'] + getKey: (item: TItem) => string + getName: (item: TItem) => string +}): AgentBuildDraftChangeItem[] { + const currentByKey = new Map(currentItems.map(item => [getKey(item), item])) + const nextByKey = new Map(nextItems.map(item => [getKey(item), item])) + const changes: AgentBuildDraftChangeItem[] = [] + + for (const item of nextItems) { + const key = getKey(item) + const currentItem = currentByKey.get(key) + if (!currentItem) { + changes.push({ + id: key, + name: getName(item), + operation: 'added', + icon: getIcon?.(item), + }) + continue + } + + if (!isEqual(item, currentItem)) { + changes.push({ + id: key, + name: getName(item), + operation: 'updated', + icon: getIcon?.(item), + }) + } + } + + for (const item of currentItems) { + const key = getKey(item) + if (nextByKey.has(key)) + continue + + changes.push({ + id: key, + name: getName(item), + operation: 'removed', + icon: getIcon?.(item), + }) + } + + return changes +} + +function getAgentBuildDraftChangeSummary({ + buildDraft, + changedKeys, + normalAgentSoulConfig, + normalDraft, +}: { + buildDraft: AgentSoulConfigFormState + changedKeys: readonly AgentBuildDraftChangedKey[] + normalAgentSoulConfig: AgentSoulConfig + normalDraft: AgentSoulConfigFormState +}): AgentBuildDraftChangeSummary { + const buildNoteChange = { + id: BUILD_NOTE_FILE_ID, + name: BUILD_NOTE_FILE_NAME, + operation: normalAgentSoulConfig.config_note?.trim() ? 'updated' : 'added', + icon: 'markdown', + descriptionKey: 'agentDetail.configure.buildDraft.buildNoteDescription', + } satisfies AgentBuildDraftChangeItem + const fileChanges = [ + buildNoteChange, + ...getItemDiff({ + currentItems: flattenFileNodes(normalDraft.files), + nextItems: flattenFileNodes(buildDraft.files), + getIcon: file => file.icon, + getKey: file => file.configName ?? file.id ?? file.name, + getName: file => file.name, + }), + ] + const skillChanges = getItemDiff({ + currentItems: normalDraft.skills, + nextItems: buildDraft.skills, + getKey: skill => skill.id || skill.name, + getName: skill => skill.name, + }) + const envVariableChanges = getItemDiff({ + currentItems: normalDraft.envVariables, + nextItems: buildDraft.envVariables, + getKey: variable => variable.id || variable.key, + getName: variable => variable.key, + }) + + return { + changedKeys, + changesCount: fileChanges.length + skillChanges.length + envVariableChanges.length, + skills: skillChanges, + files: fileChanges, + envVariables: envVariableChanges, + } +} export function useAgentConfigureBuildDraftData({ agentId, @@ -92,22 +205,36 @@ export function useAgentConfigureBuildDraftData({ const isBuildDraftActive = soulSource === 'build-draft' const buildDraftAgentSoulConfig = buildDraftData?.agent_soul as AgentSoulConfig | undefined const visibleAgentSoulConfig = isBuildDraftActive ? buildDraftAgentSoulConfig : normalAgentSoulConfig - const buildDraftChangedKeys = useMemo(() => { - if (!buildDraftAgentSoulConfig || !composerAgentSoulConfig) - return [] + const buildDraftChangeSummary = useMemo(() => { + if (!buildDraftAgentSoulConfig || !composerAgentSoulConfig) { + return { + changedKeys: [], + changesCount: 0, + skills: [], + files: [], + envVariables: [], + } + } const normalDraft = agentSoulConfigToFormState(composerAgentSoulConfig) const buildDraft = agentSoulConfigToFormState(buildDraftAgentSoulConfig) - - return (Object.keys(buildDraft) as Array) + const changedKeys = (Object.keys(buildDraft) as Array) .filter(key => !isEqual(buildDraft[key], normalDraft[key])) + + return getAgentBuildDraftChangeSummary({ + buildDraft, + changedKeys, + normalAgentSoulConfig: composerAgentSoulConfig, + normalDraft, + }) }, [buildDraftAgentSoulConfig, composerAgentSoulConfig]) return { activeVersionId: isBuildDraftActive ? `build-draft:${buildDraftDataUpdatedAt}` : activeVersionId, agentSoulConfig: visibleAgentSoulConfig, - changedKeys: buildDraftChangedKeys, - changesCount: buildDraftChangedKeys.length, + changedKeys: buildDraftChangeSummary.changedKeys, + changeSummary: buildDraftChangeSummary, + changesCount: buildDraftChangeSummary.changesCount, isActive: isBuildDraftActive, isPending: !isViewingVersion && soulSourceOverride !== 'draft' && soulSourceOverride !== 'view-version' && isBuildDraftPending, refetch: refetchBuildDraft, 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 7fa6a53537b..a3493702cf2 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 @@ -12,7 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' -import { validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation' +import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation' import { agentComposerDraftAtom, agentComposerOriginalConfigAtom, @@ -24,12 +24,6 @@ import { consoleQuery } from '@/service/client' const DRAFT_AUTOSAVE_WAIT = 5000 -class InvalidKnowledgeConfigurationError extends Error { - constructor() { - super('Agent knowledge retrieval configuration is invalid.') - } -} - export function useAgentConfigureSync({ agentId, baseConfig, @@ -42,6 +36,7 @@ export function useAgentConfigureSync({ enabled: boolean }) { const { t: tCommon } = useTranslation('common') + const getKnowledgeValidationMessage = useKnowledgeValidationMessage() const queryClient = useQueryClient() const store = useStore() const setOriginalConfig = useSetAtom(agentComposerOriginalConfigAtom) @@ -127,8 +122,6 @@ export function useAgentConfigureSync({ const latestDraftSaveRef = useRef<() => void>(() => undefined) latestDraftSaveRef.current = () => { const draft = store.get(agentComposerDraftAtom) - if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid) - return void saveComposer({ configSnapshot: getAgentSoulDraft(), @@ -145,8 +138,6 @@ export function useAgentConfigureSync({ return const draft = store.get(agentComposerDraftAtom) - if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid) - throw new InvalidKnowledgeConfigurationError() const configSnapshot = getAgentSoulDraft() const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model) debouncedSaveDraft.cancel?.() @@ -168,7 +159,6 @@ export function useAgentConfigureSync({ const draft = store.get(agentComposerDraftAtom) if ( !store.get(isAgentComposerDirtyAtom) - || !validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid ) { return } @@ -209,8 +199,7 @@ export function useAgentConfigureSync({ } if ( - !validateKnowledgeRetrievals(store.get(agentComposerDraftAtom).knowledgeRetrievals).isValid - || lastAutosavedDraftKeyRef.current === agentSoulDraftKey + lastAutosavedDraftKeyRef.current === agentSoulDraftKey ) { return } @@ -248,8 +237,11 @@ export function useAgentConfigureSync({ return const draft = store.get(agentComposerDraftAtom) - if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid) - throw new InvalidKnowledgeConfigurationError() + const knowledgeValidation = validateKnowledgeRetrievals(draft.knowledgeRetrievals) + if (!knowledgeValidation.isValid) { + toast.error(getKnowledgeValidationMessage(knowledgeValidation.firstIssue?.code) ?? tCommon('api.actionFailed')) + return + } publishInFlightRef.current = true setIsPublishInFlight(true) @@ -302,7 +294,7 @@ export function useAgentConfigureSync({ publishInFlightRef.current = false setIsPublishInFlight(false) } - }, [agentId, debouncedSaveDraft, publishAgent, queryClient, saveComposer, setOriginalConfig, setOriginalDraft, setPublishedDraft, store, tCommon]) + }, [agentId, debouncedSaveDraft, getKnowledgeValidationMessage, publishAgent, queryClient, saveComposer, setOriginalConfig, setOriginalDraft, setPublishedDraft, store, tCommon]) return { draftSavedAt, diff --git a/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx index 0f625075699..4b60e27366a 100644 --- a/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx +++ b/web/features/agent-v2/agent-detail/logs/__tests__/page.spec.tsx @@ -1,4 +1,4 @@ -import type { AgentLogListResponse, AgentLogSourceListResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { AgentLogListResponse, AgentLogMessageListResponse, AgentLogSourceListResponse } from '@dify/contracts/api/console/agent/types.gen' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -16,6 +16,7 @@ type AgentLogsQueryInput = { const mocks = vi.hoisted(() => ({ logsQueryFn: vi.fn(), logSourcesQueryFn: vi.fn(), + messagesQueryFn: vi.fn(), logsQueryOptions: vi.fn((input: AgentLogsQueryInput) => ({ queryKey: ['agent-logs', input], queryFn: () => mocks.logsQueryFn(input), @@ -24,6 +25,10 @@ const mocks = vi.hoisted(() => ({ queryKey: ['agent-log-sources', input], queryFn: () => mocks.logSourcesQueryFn(input), })), + messagesQueryOptions: vi.fn((input: AgentLogsQueryInput) => ({ + queryKey: ['agent-log-messages', input], + queryFn: () => mocks.messagesQueryFn(input), + })), })) vi.mock('@/context/i18n', () => ({ @@ -49,6 +54,13 @@ vi.mock('@/service/client', () => ({ get: { queryOptions: mocks.logsQueryOptions, }, + byConversationId: { + messages: { + get: { + queryOptions: mocks.messagesQueryOptions, + }, + }, + }, }, }, }, @@ -133,6 +145,34 @@ const logSourcesResponse: AgentLogSourceListResponse = { ], } +const messagesResponse: AgentLogMessageListResponse = { + data: [ + { + answer: 'Translated chapter summary', + answer_tokens: 12, + conversation_id: 'conversation-1', + created_at: 1781660001, + currency: 'USD', + error: null, + from_account_id: null, + from_end_user_id: 'end-user-1', + id: 'message-1', + latency: 1.234, + message_id: 'message-1', + message_tokens: 8, + query: 'Translate this chapter', + status: 'success', + total_price: '0.001', + total_tokens: 20, + updated_at: 1781660002, + }, + ], + has_more: false, + limit: 100, + page: 1, + total: 1, +} + const renderPage = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -165,6 +205,7 @@ describe('AgentLogsPage', () => { vi.clearAllMocks() mocks.logsQueryFn.mockResolvedValue(emptyLogsResponse) mocks.logSourcesQueryFn.mockResolvedValue(logSourcesResponse) + mocks.messagesQueryFn.mockResolvedValue(messagesResponse) }) describe('Query contract', () => { @@ -266,5 +307,33 @@ describe('AgentLogsPage', () => { resolveNextLogs(emptyLogsResponse) }) + + it('should open chatbot-style log detail drawer with generated messages contract when a row is clicked', async () => { + const user = userEvent.setup() + mocks.logsQueryFn.mockResolvedValue(populatedLogsResponse) + + renderPage() + + await user.click(await screen.findByRole('button', { name: 'Previous conversation' })) + + await waitFor(() => { + expect(mocks.messagesQueryOptions).toHaveBeenCalledWith({ + input: { + params: { + agent_id: 'agent-1', + conversation_id: 'conversation-1', + }, + query: { + limit: 100, + page: 1, + sort_by: 'created_at', + sort_order: 'asc', + sources: ['webapp:webapp-app-id'], + }, + }, + }) + }) + expect(await screen.findByText('Translated chapter summary')).toBeInTheDocument() + }) }) }) diff --git a/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx b/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx new file mode 100644 index 00000000000..610955b7492 --- /dev/null +++ b/web/features/agent-v2/agent-detail/logs/components/log-detail-panel.tsx @@ -0,0 +1,164 @@ +import type { AgentLogConversationItemResponse, AgentLogMessageItemResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { IChatItem } from '@/app/components/base/chat/chat/type' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { skipToken, useQuery } from '@tanstack/react-query' +import { noop } from 'es-toolkit/function' +import { useTranslation } from 'react-i18next' +import ActionButton from '@/app/components/base/action-button' +import Chat from '@/app/components/base/chat/chat' +import CopyIcon from '@/app/components/base/copy-icon' +import Loading from '@/app/components/base/loading' +import useTimestamp from '@/hooks/use-timestamp' +import { consoleQuery } from '@/service/client' + +export function AgentLogDetailPanel({ + agentId, + log, + onClose, +}: { + agentId: string + log?: AgentLogConversationItemResponse + onClose: () => void +}) { + const { t } = useTranslation() + const { t: tAgentV2 } = useTranslation('agentV2') + const { formatTime } = useTimestamp() + const messagesQuery = useQuery(consoleQuery.agent.byAgentId.logs.byConversationId.messages.get.queryOptions({ + input: log + ? { + params: { + agent_id: agentId, + conversation_id: log.conversation_id, + }, + query: { + limit: 100, + page: 1, + sort_by: 'created_at', + sort_order: 'asc', + ...(log.source ? { sources: [log.source.id] } : {}), + }, + } + : skipToken, + })) + const chatList = log + ? formatAgentLogMessages({ + conversationId: log.conversation_id, + formatLogTime: value => formatTime(value, tAgentV2('roster.dateTimeFormat')), + messages: messagesQuery.data?.data ?? [], + }) + : [] + + return ( +
+
+
+
{t('detail.conversationId', { ns: 'appLog' })}
+
+ {log && ( + <> + + {log.conversation_id}
+ )} + /> + + {log.conversation_id} + + + + + )} +
+
+
+
+ {log?.title || log?.conversation_id} +
+
+ + + +
+
+
+
+
+ {messagesQuery.isPending && ( +
+ +
+ )} + {messagesQuery.isError && ( +
+ {t('agentDetail.logs.loadFailed', { ns: 'agentV2' })} +
+ )} + {messagesQuery.isSuccess && chatList.length === 0 && ( +
+ {t('agentDetail.logs.empty', { ns: 'agentV2' })} +
+ )} + {messagesQuery.isSuccess && chatList.length > 0 && ( +
+ +
+ )} +
+
+ ) +} + +function formatAgentLogMessages({ + conversationId, + formatLogTime, + messages, +}: { + conversationId: string + formatLogTime: (value: number) => string + messages: AgentLogMessageItemResponse[] +}) { + const chatList: IChatItem[] = [] + + messages.forEach((message) => { + chatList.push({ + id: `question-${message.id}`, + content: message.query, + isAnswer: false, + parentMessageId: undefined, + }) + chatList.push({ + id: message.id, + content: message.answer || message.error || '', + conversationId, + feedbackDisabled: true, + input: { + inputs: { + query: message.query, + }, + query: message.query, + }, + isAnswer: true, + log: [ + { role: 'user', text: message.query }, + { role: 'assistant', text: message.answer || message.error || '' }, + ], + more: { + latency: message.latency.toFixed(2), + time: formatLogTime(message.created_at ?? message.updated_at ?? 0), + tokens: message.total_tokens, + }, + parentMessageId: `question-${message.id}`, + }) + }) + + return chatList +} diff --git a/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx b/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx index fd8140be430..12e3a3b1385 100644 --- a/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx +++ b/web/features/agent-v2/agent-detail/logs/components/logs-table.tsx @@ -18,12 +18,16 @@ export function AgentLogsTable({ isPending, isError, isSuccess, + selectedLogId, + onOpenLog, onRetry, }: { logs: AgentLogConversationItemResponse[] isPending: boolean isError: boolean isSuccess: boolean + selectedLogId?: string + onOpenLog: (log: AgentLogConversationItemResponse) => void onRetry: () => void }) { const { t } = useTranslation('agentV2') @@ -64,6 +68,8 @@ export function AgentLogsTable({ isPending={isPending} isError={isError} isSuccess={isSuccess} + selectedLogId={selectedLogId} + onOpenLog={onOpenLog} onRetry={onRetry} /> @@ -82,12 +88,16 @@ function AgentLogsTableBody({ isPending, isError, isSuccess, + selectedLogId, + onOpenLog, onRetry, }: { logs: AgentLogConversationItemResponse[] isPending: boolean isError: boolean isSuccess: boolean + selectedLogId?: string + onOpenLog: (log: AgentLogConversationItemResponse) => void onRetry: () => void }) { const { t } = useTranslation('agentV2') @@ -117,44 +127,63 @@ function AgentLogsTableBody({ {t('agentDetail.logs.empty')} )} - {isSuccess && logs.map(log => ( - - - { + const logTitle = log.title || log.conversation_id + const isSelected = selectedLogId === log.id + + return ( + - - - {log.title || notAvailable} - - - - - - {log.end_user_id || notAvailable} - - - {log.message_count} - - - {formatRate(log.user_rate, notAvailable)} - - - {formatRate(log.operation_rate, notAvailable)} - - - {formatLogTime(log.updated_at)} - - - {formatLogTime(log.created_at)} - - - ))} + onClick={() => onOpenLog(log)} + > + + + + + + + + + + + {log.end_user_id || notAvailable} + + + {log.message_count} + + + {formatRate(log.user_rate, notAvailable)} + + + {formatRate(log.operation_rate, notAvailable)} + + + {formatLogTime(log.updated_at)} + + + {formatLogTime(log.created_at)} + + + ) + })} ) } diff --git a/web/features/agent-v2/agent-detail/logs/page.tsx b/web/features/agent-v2/agent-detail/logs/page.tsx index 311acb05912..60e13b8f8a9 100644 --- a/web/features/agent-v2/agent-detail/logs/page.tsx +++ b/web/features/agent-v2/agent-detail/logs/page.tsx @@ -1,6 +1,15 @@ 'use client' +import type { AgentLogConversationItemResponse } from '@dify/contracts/api/console/agent/types.gen' import type { SourceFilterValue } from './components/source-picker' +import { + Drawer, + DrawerBackdrop, + DrawerContent, + DrawerPopup, + DrawerPortal, + DrawerViewport, +} from '@langgenius/dify-ui/drawer' import { Pagination } from '@langgenius/dify-ui/pagination' import { keepPreviousData, useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' @@ -10,7 +19,10 @@ import Chip from '@/app/components/base/chip' import { SearchInput } from '@/app/components/base/search-input' import Sort from '@/app/components/base/sort' import { useDocLink } from '@/context/i18n' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { consoleQuery } from '@/service/client' +import { AgentDetailSectionSurface } from '../section-surface' +import { AgentLogDetailPanel } from './components/log-detail-panel' import { AgentLogsTable } from './components/logs-table' import { AgentLogSourcePicker } from './components/source-picker' @@ -63,6 +75,8 @@ export function AgentLogsPage({ const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const docLink = useDocLink() + const media = useBreakpoints() + const isMobile = media === MediaType.mobile const [period, setPeriod] = useState('last7days') const [source, setSource] = useState([]) const [keyword, setKeyword] = useState('') @@ -72,6 +86,7 @@ export function AgentLogsPage({ }) const [page, setPage] = useState(1) const [limit, setLimit] = useState(25) + const [selectedLog, setSelectedLog] = useState() const periodItems = periodOptions.map(option => ({ value: option.value, name: t(option.labelKey), @@ -105,12 +120,13 @@ export function AgentLogsPage({ const logs = logsQuery.data?.data ?? [] const totalPages = Math.max(Math.ceil((logsQuery.data?.total ?? 0) / limit), 1) const currentPage = logsQuery.data?.page ?? page + const closeLogDetail = () => { + setSelectedLog(undefined) + void logsQuery.refetch() + } return ( -
+

@@ -194,12 +210,39 @@ export function AgentLogsPage({ isPending={logsQuery.isPending} isError={logsQuery.isError} isSuccess={logsQuery.isSuccess} + selectedLogId={selectedLog?.id} + onOpenLog={setSelectedLog} onRetry={() => { void logsQuery.refetch() }} />

+ { + if (!open) + closeLogDetail() + }} + > + + + + + + + + + + + + -
+ ) } diff --git a/web/features/agent-v2/agent-detail/monitoring/page.tsx b/web/features/agent-v2/agent-detail/monitoring/page.tsx index 791f456804b..d6087336de8 100644 --- a/web/features/agent-v2/agent-detail/monitoring/page.tsx +++ b/web/features/agent-v2/agent-detail/monitoring/page.tsx @@ -18,6 +18,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' import { consoleQuery } from '@/service/client' +import { AgentDetailSectionSurface } from '../section-surface' import { AgentMonitoringChart } from './chart' import { getAgentMonitoringMetrics } from './metrics' import { AgentMonitoringTimeRangePicker } from './time-range-picker' @@ -91,10 +92,7 @@ export function AgentMonitoringPage({ const shouldShowError = statisticsQuery.isError && !statisticsQuery.data return ( -
+

@@ -175,7 +173,7 @@ export function AgentMonitoringPage({

)} -
+ ) } diff --git a/web/features/agent-v2/agent-detail/section-surface.tsx b/web/features/agent-v2/agent-detail/section-surface.tsx new file mode 100644 index 00000000000..229512daaa9 --- /dev/null +++ b/web/features/agent-v2/agent-detail/section-surface.tsx @@ -0,0 +1,29 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' + +type AgentDetailSectionSurfaceProps = { + children: ReactNode + className?: string + label: string + panelClassName?: string +} + +export function AgentDetailSectionSurface({ + children, + className, + label, + panelClassName, +}: AgentDetailSectionSurfaceProps) { + return ( +
+
+ {children} +
+
+ ) +} diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx index 43700ac1bd7..08af8f8fbf3 100644 --- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx @@ -1,5 +1,5 @@ +import type { AgentAppPartial } from '@dify/contracts/api/console/agent/types.gen' import type { ComponentProps } from 'react' -import type { AgentRosterListItem } from '../agent-roster-list' import { toast } from '@langgenius/dify-ui/toast' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor, within } from '@testing-library/react' @@ -45,7 +45,7 @@ vi.mock('@/service/client', () => ({ }, })) -const createAgent = (overrides: Partial = {}): AgentRosterListItem => ({ +const createAgent = (overrides: Partial = {}): AgentAppPartial => ({ active_config_is_published: false, description: 'Find and summarize market materials.', id: 'agent-1', @@ -62,7 +62,7 @@ const createAgent = (overrides: Partial = {}): AgentRosterL }) const renderList = ( - agents: AgentRosterListItem[], + agents: AgentAppPartial[], overrides: Partial> = {}, ) => { const queryClient = new QueryClient() @@ -211,10 +211,21 @@ describe('AgentRosterList', () => { await user.click(screen.getByRole('menuitem', { name: /common\.operation\.duplicate/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.duplicateDialog.title' }) - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel/ })).toHaveValue('') - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.nameLabel/ })).toHaveAttribute('placeholder', 'Research Agent copy') - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })).toHaveValue('Research Assistant') - expect(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.descriptionLabel/ })).toHaveValue('Find and summarize market materials.') + const nameInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.nameLabel.*common\.label\.optional/, + }) + const roleInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel.*common\.label\.optional/, + }) + const descriptionInput = within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel.*common\.label\.optional/, + }) + expect(nameInput).toHaveValue('') + expect(nameInput).toHaveAttribute('placeholder', 'Research Agent copy') + expect(roleInput).toHaveValue('Research Assistant') + expect(roleInput).not.toBeRequired() + expect(descriptionInput).toHaveValue('Find and summarize market materials.') + expect(descriptionInput).not.toBeRequired() expect(duplicateAgentMutationFn).not.toHaveBeenCalled() }) @@ -315,7 +326,7 @@ describe('AgentRosterList', () => { }) }) - it('shows a field error when duplicating with an empty role', async () => { + it('duplicates an agent with an empty role when the role is cleared', async () => { const user = userEvent.setup() renderList([createAgent()]) @@ -326,8 +337,23 @@ describe('AgentRosterList', () => { await user.clear(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })) await user.click(within(dialog).getByRole('button', { name: 'common.operation.duplicate' })) - expect(await within(dialog).findByText('agentV2.roster.createForm.roleRequired')).toBeInTheDocument() - expect(duplicateAgentMutationFn).not.toHaveBeenCalled() + expect(duplicateAgentMutationFn).toHaveBeenCalledWith( + { + params: { + agent_id: 'agent-1', + }, + body: { + description: 'Find and summarize market materials.', + role: '', + icon: '🧸', + icon_background: '#F5F3FF', + icon_type: 'emoji', + }, + }, + expect.objectContaining({ + client: expect.any(QueryClient), + }), + ) }) it('resets the edit form draft when reopening after canceling unsaved changes', async () => { @@ -352,7 +378,7 @@ describe('AgentRosterList', () => { const reopenedDialog = await screen.findByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) expect(within(reopenedDialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })).toHaveValue('Research Agent') - expect(within(reopenedDialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })).toHaveValue('Research Assistant') + expect(within(reopenedDialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })).toHaveValue('Research Assistant') expect(within(reopenedDialog).getByRole('button', { name: 'common.operation.save' })).toBeDisabled() }) }) 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 3c06e441635..54ebe62467c 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 @@ -55,7 +55,7 @@ describe('CreateAgentDialog', () => { const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), ' Research Agent ') - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' }), ' Research Assistant ') + await user.type(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), ' Research Assistant ') await user.type(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder'), ' Find and summarize market materials. ') await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) @@ -83,7 +83,6 @@ describe('CreateAgentDialog', () => { const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), 'Research Agent') - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' }), 'Research Assistant') await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1] @@ -102,7 +101,7 @@ describe('CreateAgentDialog', () => { await user.click(screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' }), 'Research Assistant') + await user.type(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), 'Research Assistant') await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) expect(await within(dialog).findByText('agentV2.roster.createForm.nameRequired')).toBeInTheDocument() @@ -110,22 +109,23 @@ describe('CreateAgentDialog', () => { expect(mutationMock.mutate).not.toHaveBeenCalled() }) - it('shows a field error when creating with an empty role', async () => { + it('marks role and description as optional', async () => { const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: /agentV2\.roster\.createAgent/ })) const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), 'Research Agent') - await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) - expect(await within(dialog).findByText('agentV2.roster.createForm.roleRequired')).toBeInTheDocument() - expect(toastMock.error).not.toHaveBeenCalled() - expect(mutationMock.mutate).not.toHaveBeenCalled() + expect(within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel.*common\.label\.optional/, + })).not.toBeRequired() + expect(within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel.*common\.label\.optional/, + })).not.toBeRequired() }) - it('shows a field error when creating with a blank role', async () => { + it('submits an empty role when role is left blank', async () => { const user = userEvent.setup() render() @@ -133,12 +133,20 @@ describe('CreateAgentDialog', () => { const dialog = await screen.findByRole('dialog', { name: 'agentV2.roster.createDialog.title' }) await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), 'Research Agent') - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' }), ' ') await user.click(within(dialog).getByRole('button', { name: 'common.operation.create' })) - expect(await within(dialog).findByText('agentV2.roster.createForm.roleRequired')).toBeInTheDocument() - expect(toastMock.error).not.toHaveBeenCalled() - expect(mutationMock.mutate).not.toHaveBeenCalled() + expect(mutationMock.mutate).toHaveBeenCalledWith({ + body: { + name: 'Research Agent', + description: '', + role: '', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) }) it('keeps the form open when the backdrop is clicked', async () => { diff --git a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx index eca96683382..84c6c4c0bec 100644 --- a/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/edit-agent-dialog.spec.tsx @@ -123,8 +123,8 @@ describe('EditAgentDialog', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' }), ' Market Analyst ') + await user.clear(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })) + await user.type(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ }), ' Market Analyst ') await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) expect(mutationMock.mutate).toHaveBeenCalledWith({ @@ -190,37 +190,45 @@ describe('EditAgentDialog', () => { expect(mutationMock.mutate).not.toHaveBeenCalled() }) - it('shows a field error when saving with an empty role', async () => { - const user = userEvent.setup() + it('marks role and description as optional', () => { renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })) - const saveButton = within(dialog).getByRole('button', { name: 'common.operation.save' }) - expect(saveButton).not.toBeDisabled() - await user.click(saveButton) - - expect(await within(dialog).findByText('agentV2.roster.createForm.roleRequired')).toBeInTheDocument() - expect(toastMock.error).not.toHaveBeenCalled() - expect(mutationMock.mutate).not.toHaveBeenCalled() + expect(within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.roleLabel.*common\.label\.optional/, + })).not.toBeRequired() + expect(within(dialog).getByRole('textbox', { + name: /agentV2\.roster\.createForm\.descriptionLabel.*common\.label\.optional/, + })).not.toBeRequired() }) - it('shows a field error when saving with a blank role', async () => { + it('submits an empty role when the role is cleared', async () => { const user = userEvent.setup() renderDialog() const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.editDialog.title' }) - await user.clear(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })) - await user.type(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' }), ' ') + await user.clear(within(dialog).getByRole('textbox', { name: /agentV2\.roster\.createForm\.roleLabel/ })) const saveButton = within(dialog).getByRole('button', { name: 'common.operation.save' }) expect(saveButton).not.toBeDisabled() await user.click(saveButton) - expect(await within(dialog).findByText('agentV2.roster.createForm.roleRequired')).toBeInTheDocument() - expect(toastMock.error).not.toHaveBeenCalled() - expect(mutationMock.mutate).not.toHaveBeenCalled() + expect(mutationMock.mutate).toHaveBeenCalledWith({ + params: { + agent_id: 'agent-1', + }, + body: { + name: 'Research Agent', + description: 'Find and summarize market materials.', + role: '', + icon_type: 'emoji', + icon: '🧸', + icon_background: '#F5F3FF', + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + })) }) it('keeps the form open when the backdrop is clicked', async () => { diff --git a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx index 7756f2cc23e..33763116c92 100644 --- a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx @@ -1,7 +1,7 @@ -import type { RosterFilterValue } from '../roster-filter' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, within } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithNuqs } from '@/test/nuqs-testing' import { RosterToolbar } from '../roster-toolbar' vi.mock('@/next/navigation', () => ({ @@ -11,34 +11,27 @@ vi.mock('@/next/navigation', () => ({ })) const renderToolbar = ({ - filter = 'all', - onFilterChange = vi.fn(), + searchParams = '', }: { - filter?: RosterFilterValue - onFilterChange?: (value: RosterFilterValue) => void + searchParams?: string } = {}) => { const queryClient = new QueryClient() - render( + return renderWithNuqs( , + { searchParams }, ) - - return { onFilterChange } } describe('RosterToolbar', () => { it('enables roster filters and emits the selected filter', async () => { const user = userEvent.setup() - const { onFilterChange } = renderToolbar() + const { onUrlUpdate } = renderToolbar() const publishedFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.published/ }) const draftsFilter = screen.getByRole('button', { name: /agentV2\.roster\.filters\.drafts/ }) @@ -48,7 +41,9 @@ describe('RosterToolbar', () => { await user.click(publishedFilter) - expect(onFilterChange).toHaveBeenCalledWith('published') + expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ + queryString: '?filter=published', + })) }) it('renders stable filter count badges and omits the all count', () => { @@ -62,4 +57,35 @@ describe('RosterToolbar', () => { expect(within(publishedFilter).getByText('1')).toBeInTheDocument() expect(within(draftsFilter).getByText('2')).toBeInTheDocument() }) + + it('renders created-by-me filtering and emits checked state', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderToolbar() + + const createdByMeFilter = screen.getByRole('checkbox', { name: 'agentV2.roster.filters.createdByMe' }) + + expect(createdByMeFilter).toHaveAttribute('aria-checked', 'false') + + await user.click(createdByMeFilter) + + expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ + queryString: '?created_by_me=true', + })) + }) + + it('renders sort options and emits the selected sort strategy', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderToolbar() + + const sortSelect = screen.getByRole('combobox', { name: 'agentV2.roster.sort.label' }) + + expect(screen.getByText('agentV2.roster.sort.lastModified')).toBeInTheDocument() + + await user.click(sortSelect) + await user.click(await screen.findByRole('option', { name: 'agentV2.roster.sort.recentlyCreated' })) + + expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ + queryString: '?sort_by=recently_created', + })) + }) }) diff --git a/web/features/agent-v2/roster/components/agent-form-fields.tsx b/web/features/agent-v2/roster/components/agent-form-fields.tsx index c3f1fe7b025..310616385a3 100644 --- a/web/features/agent-v2/roster/components/agent-form-fields.tsx +++ b/web/features/agent-v2/roster/components/agent-form-fields.tsx @@ -28,6 +28,7 @@ export function AgentFormFields({ role, }: AgentFormFieldsProps) { const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') return (
@@ -80,34 +81,29 @@ export function AgentFormFields({ { - if (typeof value === 'string' && value.length > 0 && !value.trim()) - return t('roster.createForm.roleRequired') - - return null - }} > {t('roster.createForm.roleLabel')} + + {tCommon('label.optional')} + -
- {t('roster.createForm.roleRequired')} - -
{t('roster.createForm.descriptionLabel')} + + {tCommon('label.optional')} +