Compare commits

..

9 Commits

Author SHA1 Message Date
yyh
374842f75d fix(webapp): restore settings dialog scroll behavior 2026-05-15 15:27:24 +08:00
b41338cd08 chore(layout): reintroduce AmplitudeProvider in common layouts for analytics tracking (#36208)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-15 06:33:31 +00:00
28153df4d3 chore: enchance copywriting in none education plan warning modal (#36201)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-05-15 05:08:06 +00:00
3bc3386535 refactor(install): improve layout and scrolling behavior for plugin installation step (#36199)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-05-15 03:12:14 +00:00
7654f14241 fix: replace deprecated testcontainers log waits (#36125) 2026-05-15 01:30:59 +00:00
yyh
194b54bae4 fix: allow tag rename without type payload (#36182)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-15 01:29:17 +00:00
0e16d36edb fix(commands): purge tenant tool credentials on reset-encrypt-key-pair (#35396) (#35843)
Co-authored-by: xr843 <xianren843@protonmail.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 16:25:54 +00:00
432a6412a3 fix(security): tenant-scope FilePreviewApi text-extract endpoint (GHSA-2qwc-c2cc-2xwv) (#35797)
Signed-off-by: xr843 <137012659+xr843@users.noreply.github.com>
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 16:13:04 +00:00
55d05fe52d fix(security): enforce tenant scoping on app trace-config endpoints (GHSA-48xc-wmw8-3jr3) (#35793)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ido Shani <ido@zafran.io>
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-05-14 15:59:31 +00:00
86 changed files with 687 additions and 1686 deletions

View File

@ -14,6 +14,7 @@ from libs.rsa import generate_key_pair
from models import Tenant
from models.model import App, AppMode, Conversation
from models.provider import Provider, ProviderModel
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
logger = logging.getLogger(__name__)
@ -23,13 +24,16 @@ DB_UPGRADE_LOCK_TTL_SECONDS = 60
@click.command(
"reset-encrypt-key-pair",
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
"After the reset, all LLM credentials will become invalid, "
"requiring re-entry."
"After the reset, all LLM credentials and tool provider credentials "
"(builtin / API / MCP) will be purged, requiring re-entry. "
"Only support SELF_HOSTED mode.",
)
@click.confirmation_option(
prompt=click.style(
"Are you sure you want to reset encrypt key pair? This operation cannot be rolled back!", fg="red"
"Are you sure you want to reset encrypt key pair? "
"This will also purge builtin / API / MCP tool provider records for every tenant. "
"This operation cannot be rolled back!",
fg="red",
)
)
def reset_encrypt_key_pair():
@ -53,6 +57,13 @@ def reset_encrypt_key_pair():
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
# Purge tool provider records that hold credentials encrypted under the
# tenant key. Leaving them in place causes /console/api/workspaces/current/
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
click.echo(
click.style(
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",

View File

@ -3,7 +3,6 @@ import re
import uuid
from datetime import datetime
from typing import Any, Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
@ -850,10 +849,11 @@ class AppTraceApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_id: UUID):
@get_app_model
def get(self, app_model):
"""Get app trace"""
with session_factory.create_session() as session:
app_trace_config = OpsTraceManager.get_app_tracing_config(str(app_id), session)
app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session)
return app_trace_config
@ -867,12 +867,13 @@ class AppTraceApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
def post(self, app_id: UUID):
@get_app_model
def post(self, app_model):
# add app trace
args = AppTracePayload.model_validate(console_ns.payload)
OpsTraceManager.update_app_tracing_config(
app_id=str(app_id),
app_id=app_model.id,
enabled=args.enabled,
tracing_provider=args.tracing_provider,
)

View File

@ -1,5 +1,4 @@
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource, fields
@ -9,8 +8,10 @@ from werkzeug.exceptions import BadRequest
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from libs.login import login_required
from models import App
from services.ops_service import OpsService
@ -43,11 +44,14 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_id: UUID):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
@get_app_model
def get(self, app_model: App):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
try:
trace_config = OpsService.get_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
trace_config = OpsService.get_tracing_app_config(
app_id=app_model.id, tracing_provider=args.tracing_provider
)
if not trace_config:
return {"has_not_configured": True}
return trace_config
@ -65,13 +69,14 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self, app_id: UUID):
@get_app_model
def post(self, app_model: App):
"""Create a new trace app configuration"""
args = TraceConfigPayload.model_validate(console_ns.payload)
try:
result = OpsService.create_tracing_app_config(
app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
)
if not result:
raise TracingConfigIsExist()
@ -90,13 +95,14 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def patch(self, app_id: UUID):
@get_app_model
def patch(self, app_model: App):
"""Update an existing trace app configuration"""
args = TraceConfigPayload.model_validate(console_ns.payload)
try:
result = OpsService.update_tracing_app_config(
app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
)
if not result:
raise TracingConfigNotExist()
@ -113,12 +119,13 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
def delete(self, app_id: UUID):
@get_app_model
def delete(self, app_model: App):
"""Delete an existing trace app configuration"""
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
try:
result = OpsService.delete_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
result = OpsService.delete_tracing_app_config(app_id=app_model.id, tracing_provider=args.tracing_provider)
if not result:
raise TracingConfigNotExist()
return {"result": "success"}, 204

View File

@ -105,7 +105,8 @@ class FilePreviewApi(Resource):
@account_initialization_required
def get(self, file_id):
file_id = str(file_id)
text = FileService(db.engine).get_file_preview(file_id)
_, tenant_id = current_account_with_tenant()
text = FileService(db.engine).get_file_preview(file_id, tenant_id)
return {"content": text}

View File

@ -25,6 +25,10 @@ class TagBasePayload(BaseModel):
type: TagType = Field(description="Tag type")
class TagUpdateRequestPayload(BaseModel):
name: str = Field(description="Tag name", min_length=1, max_length=50)
class TagBindingPayload(BaseModel):
tag_ids: list[str] = Field(description="Tag IDs to bind")
target_id: str = Field(description="Target ID to bind tags to")
@ -68,6 +72,7 @@ class TagResponse(ResponseModel):
register_schema_models(
console_ns,
TagBasePayload,
TagUpdateRequestPayload,
TagBindingPayload,
TagBindingRemovePayload,
TagListQueryParam,
@ -118,7 +123,7 @@ class TagListApi(Resource):
@console_ns.route("/tags/<uuid:tag_id>")
class TagUpdateDeleteApi(Resource):
@console_ns.expect(console_ns.models[TagBasePayload.__name__])
@console_ns.expect(console_ns.models[TagUpdateRequestPayload.__name__])
@setup_required
@login_required
@account_initialization_required
@ -129,8 +134,8 @@ class TagUpdateDeleteApi(Resource):
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
raise Forbidden()
payload = TagBasePayload.model_validate(console_ns.payload or {})
tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=payload.type), tag_id)
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id)
binding_count = TagService.get_tag_binding_count(tag_id)

View File

@ -31,7 +31,9 @@ from services.tag_service import (
TagBindingCreatePayload,
TagBindingDeletePayload,
TagService,
UpdateTagPayload,
)
from services.tag_service import (
UpdateTagPayload as UpdateTagServicePayload,
)
register_enum_models(service_api_ns, DatasetPermissionEnum)
@ -556,7 +558,7 @@ class DatasetTagsApi(DatasetApiResource):
payload = TagUpdatePayload.model_validate(service_api_ns.payload or {})
tag_id = payload.tag_id
tag = TagService.update_tags(UpdateTagPayload(name=payload.name, type=TagType.KNOWLEDGE), tag_id)
tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id)
binding_count = TagService.get_tag_binding_count(tag_id)

View File

@ -7515,7 +7515,7 @@ Remove one or more tag bindings from a target.
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| tag_id | path | | Yes | string |
| payload | body | | Yes | [TagBasePayload](#tagbasepayload) |
| payload | body | | Yes | [TagUpdateRequestPayload](#tagupdaterequestpayload) |
##### Responses
@ -13456,6 +13456,12 @@ Tag type
| ---- | ---- | ----------- | -------- |
| TagType | string | Tag type | |
#### TagUpdateRequestPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | Tag name | Yes |
#### TenantAccountRole
| Name | Type | Description | Required |

View File

@ -172,12 +172,14 @@ class FileService:
return upload_file
def get_file_preview(self, file_id: str):
def get_file_preview(self, file_id: str, tenant_id: str):
"""
Return a short text preview extracted from a document file.
"""
with self._session_maker(expire_on_commit=False) as session:
upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1))
upload_file = session.scalar(
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id).limit(1)
)
if not upload_file:
raise NotFound("File not found")

View File

@ -21,7 +21,6 @@ class SaveTagPayload(BaseModel):
class UpdateTagPayload(BaseModel):
name: str = Field(min_length=1, max_length=50)
type: TagType
class TagBindingCreatePayload(BaseModel):

View File

@ -22,7 +22,7 @@ from sqlalchemy import Engine, text
from sqlalchemy.orm import Session
from testcontainers.core.container import DockerContainer
from testcontainers.core.network import Network
from testcontainers.core.waiting_utils import wait_for_logs
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
@ -54,6 +54,10 @@ def _auto_close[T: _CloserProtocol](closer: T) -> Generator[T, None, None]:
closer.close()
def _wait_for_log_message(message: str, timeout: int) -> LogMessageWaitStrategy:
return LogMessageWaitStrategy(message).with_startup_timeout(timeout)
class DifyTestContainers:
"""
Manages all test containers required for Dify integration tests.
@ -99,6 +103,7 @@ class DifyTestContainers:
self.postgres = PostgresContainer(
image="postgres:14-alpine",
).with_network(self.network)
self.postgres.waiting_for(_wait_for_log_message("is ready to accept connections", 30))
self.postgres.start()
db_host = self.postgres.get_container_host_ip()
db_port = self.postgres.get_exposed_port(5432)
@ -115,9 +120,6 @@ class DifyTestContainers:
self.postgres.dbname,
)
# Wait for PostgreSQL to be ready
logger.info("Waiting for PostgreSQL to be ready to accept connections...")
wait_for_logs(self.postgres, "is ready to accept connections", timeout=30)
logger.info("PostgreSQL container is ready and accepting connections")
conn = psycopg2.connect(
@ -152,6 +154,7 @@ class DifyTestContainers:
# Redis is used for storing session data, cache entries, and temporary data
logger.info("Initializing Redis container...")
self.redis = RedisContainer(image="redis:6-alpine", port=6379).with_network(self.network)
self.redis.waiting_for(_wait_for_log_message("Ready to accept connections", 30))
self.redis.start()
redis_host = self.redis.get_container_host_ip()
redis_port = self.redis.get_exposed_port(6379)
@ -159,9 +162,6 @@ class DifyTestContainers:
os.environ["REDIS_PORT"] = str(redis_port)
logger.info("Redis container started successfully - Host: %s, Port: %s", redis_host, redis_port)
# Wait for Redis to be ready
logger.info("Waiting for Redis to be ready to accept connections...")
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
logger.info("Redis container is ready and accepting connections")
# Start Dify Sandbox container for code execution environment.
@ -170,6 +170,7 @@ class DifyTestContainers:
sandbox_image = os.getenv(SANDBOX_TEST_IMAGE_ENV, DEFAULT_SANDBOX_TEST_IMAGE)
self.dify_sandbox = DockerContainer(image=sandbox_image).with_network(self.network)
self.dify_sandbox.with_exposed_ports(8194)
self.dify_sandbox.waiting_for(_wait_for_log_message("config init success", 60))
self.dify_sandbox.env = {
"API_KEY": "test_api_key",
}
@ -185,9 +186,6 @@ class DifyTestContainers:
sandbox_port,
)
# Wait for Dify Sandbox to be ready
logger.info("Waiting for Dify Sandbox to be ready to accept connections...")
wait_for_logs(self.dify_sandbox, "config init success", timeout=60)
logger.info("Dify Sandbox container is ready and accepting connections")
# Start Dify Plugin Daemon container for plugin management
@ -197,6 +195,7 @@ class DifyTestContainers:
self.network
)
self.dify_plugin_daemon.with_exposed_ports(5002)
self.dify_plugin_daemon.waiting_for(_wait_for_log_message("start plugin manager daemon", 60))
# Get container internal network addresses
postgres_container_name = self.postgres.get_wrapped_container().name
redis_container_name = self.redis.get_wrapped_container().name
@ -243,9 +242,6 @@ class DifyTestContainers:
plugin_daemon_port,
)
# Wait for Dify Plugin Daemon to be ready
logger.info("Waiting for Dify Plugin Daemon to be ready to accept connections...")
wait_for_logs(self.dify_plugin_daemon, "start plugin manager daemon", timeout=60)
logger.info("Dify Plugin Daemon container is ready and accepting connections")
except Exception as e:
logger.warning("Failed to start Dify Plugin Daemon container: %s", e)

View File

@ -8,7 +8,9 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from flask.testing import FlaskClient
from pydantic import ValidationError
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, NotFound
from controllers.console import console_ns
@ -57,6 +59,12 @@ from controllers.console.app.workflow_app_log import WorkflowAppLogQuery
from controllers.console.app.workflow_draft_variable import WorkflowDraftVariableUpdatePayload
from controllers.console.app.workflow_statistic import WorkflowStatisticQuery
from controllers.console.app.workflow_trigger import Parser, ParserEnable
from models.model import AppMode
from tests.test_containers_integration_tests.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
create_console_app,
)
def _unwrap(func):
@ -270,6 +278,35 @@ class TestOpsTraceEndpoints:
def app(self, flask_app_with_containers: Flask):
return flask_app_with_containers
@pytest.mark.parametrize(
"path_template",
[
"/console/api/apps/{app_id}/trace-config?tracing_provider=langfuse",
"/console/api/apps/{app_id}/trace",
],
)
def test_trace_endpoints_hide_apps_from_other_tenants(
self,
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
path_template: str,
):
account, _tenant = create_console_account_and_tenant(db_session_with_containers)
foreign_account, foreign_tenant = create_console_account_and_tenant(db_session_with_containers)
foreign_app = create_console_app(
db_session_with_containers,
tenant_id=foreign_tenant.id,
account_id=foreign_account.id,
mode=AppMode.CHAT,
)
response = test_client_with_containers.get(
path_template.format(app_id=foreign_app.id),
headers=authenticate_console_client(test_client_with_containers, account),
)
assert response.status_code == 404
def test_ops_trace_query_basic(self):
query = TraceProviderQuery(tracing_provider="langfuse")
assert query.tracing_provider == "langfuse"
@ -289,7 +326,7 @@ class TestOpsTraceEndpoints:
)
with app.test_request_context("/?tracing_provider=langfuse"):
result = method(app_id="app-1")
result = method(app_model=MagicMock(id="app-1"))
assert result == {"has_not_configured": True}
@ -308,7 +345,7 @@ class TestOpsTraceEndpoints:
json={"tracing_provider": "langfuse", "tracing_config": {"api_key": "k"}},
):
with pytest.raises(BadRequest):
method(app_id="app-1")
method(app_model=MagicMock(id="app-1"))
def test_trace_app_config_delete_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
api = ops_trace_module.TraceAppConfigApi()
@ -322,7 +359,7 @@ class TestOpsTraceEndpoints:
with app.test_request_context("/?tracing_provider=langfuse"):
with pytest.raises(BadRequest):
method(app_id="app-1")
method(app_model=MagicMock(id="app-1"))
class TestSiteEndpoints:

View File

@ -6,17 +6,17 @@ import uuid
from flask.testing import FlaskClient
from sqlalchemy.orm import Session
from configs import dify_config
from constants import HEADER_NAME_CSRF_TOKEN
from graphon.enums import WorkflowExecutionStatus
from libs.datetime_utils import naive_utc_now
from libs.token import _real_cookie_name, generate_csrf_token
from models import Account, DifySetup, Tenant, TenantAccountJoin
from models import Account, Tenant, TenantAccountJoin
from models.account import AccountStatus, TenantAccountRole, TenantStatus
from models.enums import ConversationFromSource, CreatorUserRole
from models.model import App, AppMode, Conversation, Message
from models.workflow import WorkflowRun
from services.account_service import AccountService
from tests.test_containers_integration_tests.controllers.console.helpers import ensure_dify_setup
def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
@ -47,9 +47,7 @@ def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
account.timezone = "UTC"
db_session.commit()
dify_setup = DifySetup(version=dify_config.project.version)
db_session.add(dify_setup)
db_session.commit()
ensure_dify_setup(db_session)
return account, tenant

View File

@ -514,7 +514,7 @@ class TestFileService:
db_session_with_containers.commit()
result = FileService(engine).get_file_preview(file_id=upload_file.id)
result = FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id)
assert result == "extracted text content"
mock_external_service_dependencies["extract_processor"].load_from_upload_file.assert_called_once()
@ -529,7 +529,7 @@ class TestFileService:
non_existent_id = str(fake.uuid4())
with pytest.raises(NotFound, match="File not found"):
FileService(engine).get_file_preview(file_id=non_existent_id)
FileService(engine).get_file_preview(file_id=non_existent_id, tenant_id=str(fake.uuid4()))
def test_get_file_preview_unsupported_file_type(
self, db_session_with_containers: Session, engine, mock_external_service_dependencies
@ -549,7 +549,7 @@ class TestFileService:
db_session_with_containers.commit()
with pytest.raises(UnsupportedFileTypeError):
FileService(engine).get_file_preview(file_id=upload_file.id)
FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id)
def test_get_file_preview_text_truncation(
self, db_session_with_containers: Session, engine, mock_external_service_dependencies
@ -572,7 +572,7 @@ class TestFileService:
long_text = "x" * 5000 # Longer than PREVIEW_WORDS_LIMIT
mock_external_service_dependencies["extract_processor"].load_from_upload_file.return_value = long_text
result = FileService(engine).get_file_preview(file_id=upload_file.id)
result = FileService(engine).get_file_preview(file_id=upload_file.id, tenant_id=upload_file.tenant_id)
assert len(result) == 3000 # PREVIEW_WORDS_LIMIT
assert result == "x" * 3000

View File

@ -759,7 +759,7 @@ class TestTagService:
tag = TagService.save_tags(tag_args)
# Update args
update_args = UpdateTagPayload(name="updated_name", type="knowledge")
update_args = UpdateTagPayload(name="updated_name")
# Act: Execute the method under test
result = TagService.update_tags(update_args, tag.id)
@ -799,7 +799,7 @@ class TestTagService:
non_existent_tag_id = str(uuid.uuid4())
update_args = UpdateTagPayload(name="updated_name", type="knowledge")
update_args = UpdateTagPayload(name="updated_name")
# Act & Assert: Verify proper error handling
with pytest.raises(NotFound) as exc_info:
@ -830,7 +830,7 @@ class TestTagService:
tag2 = TagService.save_tags(tag2_args)
# Try to update second tag with first tag's name
update_args = UpdateTagPayload(name="first_tag", type="app")
update_args = UpdateTagPayload(name="first_tag")
# Act & Assert: Verify proper error handling
with pytest.raises(ValueError) as exc_info:

View File

@ -0,0 +1,108 @@
"""Unit tests for the reset-encrypt-key-pair CLI command (#35396).
The command must purge every table that stores ciphertext encrypted with the
tenant's asymmetric key, otherwise stale rows cause downstream API failures
such as `/console/api/workspaces/current/tool-providers` returning 500.
"""
from unittest.mock import MagicMock, patch
import commands
from commands import system as system_commands
from models.provider import Provider, ProviderModel
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
def _invoke_reset() -> int:
try:
commands.reset_encrypt_key_pair.callback()
except SystemExit as e:
return int(e.code or 0)
return 0
def _delete_targets(session_mock: MagicMock) -> list:
"""Extract the model class targeted by each `delete(...)` call on the session."""
targets = []
for call in session_mock.execute.call_args_list:
stmt = call.args[0]
# `delete(Foo)` constructs a `Delete` statement whose entity is `Foo`.
try:
targets.append(stmt.table.name)
except AttributeError:
targets.append(repr(stmt))
return targets
def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys):
monkeypatch.setattr(system_commands.dify_config, "EDITION", "CLOUD")
exit_code = _invoke_reset()
captured = capsys.readouterr()
assert exit_code == 0
assert "only for SELF_HOSTED" in captured.out
def test_reset_purges_provider_and_tool_tables_for_each_tenant(monkeypatch, capsys):
"""The command must purge LLM provider rows AND every tool provider table
that stores ciphertext encrypted under the tenant key (#35396)."""
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
fake_tenant = MagicMock(id="tenant-abc", encrypt_public_key="old-key")
session = MagicMock()
session.scalars.return_value.all.return_value = [fake_tenant]
fake_sessionmaker = MagicMock()
fake_sessionmaker.begin.return_value.__enter__.return_value = session
fake_sessionmaker.begin.return_value.__exit__.return_value = False
with (
patch.object(system_commands, "db", MagicMock()),
patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker),
):
exit_code = _invoke_reset()
captured = capsys.readouterr()
assert exit_code == 0
assert "tenant-abc" in captured.out
# New key pair generated and assigned.
assert fake_tenant.encrypt_public_key == "new-key-tenant-abc"
# Every encrypted-credential table should have been purged for this tenant.
table_names = _delete_targets(session)
expected = {
Provider.__tablename__,
ProviderModel.__tablename__,
BuiltinToolProvider.__tablename__,
ApiToolProvider.__tablename__,
MCPToolProvider.__tablename__,
}
assert expected.issubset(set(table_names)), f"missing purges: expected {expected}, got {table_names}"
def test_reset_iterates_all_tenants(monkeypatch, capsys):
"""Multi-tenant deployments must purge every tenant, not just the first."""
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
tenants = [MagicMock(id=f"tenant-{i}", encrypt_public_key="old") for i in range(3)]
session = MagicMock()
session.scalars.return_value.all.return_value = tenants
fake_sessionmaker = MagicMock()
fake_sessionmaker.begin.return_value.__enter__.return_value = session
fake_sessionmaker.begin.return_value.__exit__.return_value = False
with (
patch.object(system_commands, "db", MagicMock()),
patch.object(system_commands, "sessionmaker", return_value=fake_sessionmaker),
):
_invoke_reset()
# Five purges per tenant × 3 tenants = 15 execute calls.
assert session.execute.call_count == 15
for tenant in tenants:
assert tenant.encrypt_public_key == f"new-key-{tenant.id}"

View File

@ -14,6 +14,7 @@ from controllers.console.tag.tags import (
TagUpdateDeleteApi,
)
from models.enums import TagType
from services.tag_service import UpdateTagPayload
def unwrap(func):
@ -147,7 +148,7 @@ class TestTagUpdateDeleteApi:
api = TagUpdateDeleteApi()
method = unwrap(api.patch)
payload = {"name": "updated", "type": "knowledge"}
payload = {"name": "updated"}
with app.test_request_context("/", json=payload):
with (
@ -159,7 +160,7 @@ class TestTagUpdateDeleteApi:
patch(
"controllers.console.tag.tags.TagService.update_tags",
return_value=tag,
),
) as update_tags_mock,
patch(
"controllers.console.tag.tags.TagService.get_tag_binding_count",
return_value=3,
@ -168,6 +169,9 @@ class TestTagUpdateDeleteApi:
result, status = method(api, "tag-1")
assert status == 200
update_payload, tag_id = update_tags_mock.call_args.args
assert update_payload == UpdateTagPayload(name="updated")
assert tag_id == "tag-1"
assert result["binding_count"] == "3"
def test_patch_forbidden(self, app: Flask, readonly_user, payload_patch):

View File

@ -278,7 +278,7 @@ class TestFileApiPost:
class TestFilePreviewApi:
def test_get_preview(self, app, mock_file_service):
def test_get_preview(self, app, mock_account_context, mock_file_service):
api = FilePreviewApi()
get_method = unwrap(api.get)
mock_file_service.get_file_preview.return_value = "preview text"

View File

@ -221,7 +221,7 @@ class TestFileService:
mock_extract.return_value = "Extracted text content"
# Execute
result = file_service.get_file_preview("file_id")
result = file_service.get_file_preview("file_id", "tenant_id")
# Assert
assert result == "Extracted text content"
@ -229,7 +229,7 @@ class TestFileService:
def test_get_file_preview_not_found(self, file_service, mock_db_session):
mock_db_session.scalar.return_value = None
with pytest.raises(NotFound, match="File not found"):
file_service.get_file_preview("non_existent")
file_service.get_file_preview("non_existent", "tenant_id")
def test_get_file_preview_unsupported_type(self, file_service, mock_db_session):
upload_file = MagicMock(spec=UploadFile)
@ -237,7 +237,7 @@ class TestFileService:
upload_file.extension = "exe"
mock_db_session.scalar.return_value = upload_file
with pytest.raises(UnsupportedFileTypeError):
file_service.get_file_preview("file_id")
file_service.get_file_preview("file_id", "tenant_id")
def test_get_image_preview_success(self, file_service, mock_db_session):
# Setup

View File

@ -98,16 +98,35 @@ uv run --extra server uvicorn dify_agent.server.app:app \
`ServerSettings` reads `.env` from the current `dify-agent` directory, or from
`dify-agent/.env` when the command is run from the repository root.
## Create a Python client example
## Create a one-file uv script client
In another shell, keep working from the `dify-agent` directory. Create
`run_dify_agent_client.py` with the example below, then replace the placeholder
tenant id and provider credential values.
In another shell, keep working from the `dify-agent` directory and create this
script. The script depends on the local `dify-agent` package only; it does not
install the server extra because it talks to the already running server through
the public Python client.
```bash
DIFY_AGENT_PACKAGE_URL="$(python3 - <<'PY'
from pathlib import Path
print(Path.cwd().resolve().as_uri())
PY
)"
cat > ./run_dify_agent_client.py <<PY
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "dify-agent @ ${DIFY_AGENT_PACKAGE_URL}",
# ]
# ///
```python {test="skip" lint="skip"}
import asyncio
import json
import os
import sys
from typing import Any
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from dify_agent.client import Client
@ -120,39 +139,55 @@ from dify_agent.layers.dify_plugin import (
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, RunComposition, RunLayerSpec
API_BASE_URL = "http://127.0.0.1:8000"
TENANT_ID = "replace-with-tenant-id"
PLUGIN_ID = "langgenius/openai"
USER_ID: str | None = None
# Keep these aligned with DIFY_AGENT_PROVIDER and DIFY_AGENT_MODEL_NAME in dify-agent/.env.
MODEL_PROVIDER = "replace-with-provider-from-dify-agent-env"
MODEL_NAME = "replace-with-model-from-dify-agent-env"
MODEL_CREDENTIALS: dict[str, str | int | float | bool | None] = {
"api_key": "replace-with-provider-key",
}
SYSTEM_PROMPT = "You are a concise assistant."
USER_PROMPT = "用一句话介绍 Dify Agent。"
def env(name: str, default: str | None = None) -> str:
value = os.environ.get(name, default)
if value is None or value == "":
raise SystemExit(f"Missing required environment variable: {name}")
return value
def build_request() -> CreateRunRequest:
return CreateRunRequest(
def load_credentials() -> dict[str, Any]:
raw = env("DIFY_AGENT_MODEL_CREDENTIALS_JSON")
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(f"DIFY_AGENT_MODEL_CREDENTIALS_JSON must be valid JSON: {exc}") from exc
if not isinstance(data, dict):
raise SystemExit("DIFY_AGENT_MODEL_CREDENTIALS_JSON must be a JSON object")
return data
async def main() -> int:
api_base_url = env("DIFY_AGENT_SERVER_URL", "http://127.0.0.1:8000")
tenant_id = env("DIFY_AGENT_TENANT_ID")
plugin_id = env("DIFY_AGENT_PLUGIN_ID", "langgenius/openai")
user_id = os.environ.get("DIFY_AGENT_USER_ID") or None
model_provider = env("DIFY_AGENT_PROVIDER", "openai")
model_name = env("DIFY_AGENT_MODEL_NAME", "gpt-4o-mini")
model_credentials = load_credentials()
system_prompt = env("DIFY_AGENT_SYSTEM_PROMPT", "You are a concise assistant.")
user_prompt = env("DIFY_AGENT_PROMPT", "Say hello from the Dify Agent client.")
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type=PLAIN_PROMPT_LAYER_TYPE_ID,
config=PromptLayerConfig(prefix=SYSTEM_PROMPT, user=USER_PROMPT),
config=PromptLayerConfig(prefix=system_prompt, user=user_prompt),
),
RunLayerSpec(
name="plugin",
type=DIFY_PLUGIN_LAYER_TYPE_ID,
config=DifyPluginLayerConfig(
tenant_id=TENANT_ID,
plugin_id=PLUGIN_ID,
user_id=USER_ID,
tenant_id=tenant_id,
plugin_id=plugin_id,
user_id=user_id,
),
),
RunLayerSpec(
@ -160,19 +195,17 @@ def build_request() -> CreateRunRequest:
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
model_provider=MODEL_PROVIDER,
model=MODEL_NAME,
credentials=MODEL_CREDENTIALS,
model_provider=model_provider,
model=model_name,
credentials=model_credentials,
),
),
],
),
)
async def main() -> int:
async with Client(base_url=API_BASE_URL, stream_timeout=None) as client:
run = await client.create_run(build_request())
async with Client(base_url=api_base_url, stream_timeout=None) as client:
run = await client.create_run(request)
print(f"created run: {run.run_id}, status={run.status}")
async for event in client.stream_events(run.run_id):
@ -192,26 +225,35 @@ async def main() -> int:
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
PY
chmod +x ./run_dify_agent_client.py
```
## Run the client example
## Configure the client request and run it
The server-side `.env` controls how Dify Agent reaches the plugin daemon. The
client example controls which tenant/plugin/provider/model and provider
credentials the run uses.
Run the example from the `dify-agent` directory:
client request controls which tenant/plugin/provider/model and provider
credentials the run uses. Configure those values before executing the script:
```bash
uv run python ./run_dify_agent_client.py
export DIFY_AGENT_SERVER_URL=http://127.0.0.1:8000
export DIFY_AGENT_TENANT_ID=replace-with-tenant-id
export DIFY_AGENT_PLUGIN_ID=langgenius/openai
export DIFY_AGENT_PROVIDER=openai
export DIFY_AGENT_MODEL_NAME=gpt-4o-mini
export DIFY_AGENT_MODEL_CREDENTIALS_JSON='{"api_key":"replace-with-provider-key"}'
export DIFY_AGENT_PROMPT='用一句话介绍 Dify Agent。'
./run_dify_agent_client.py
```
The shape of `MODEL_CREDENTIALS` depends on the selected plugin provider's
credential schema. The `{"api_key":"..."}` value above is only an OpenAI-style
example.
Set `MODEL_PROVIDER` and `MODEL_NAME` to the same values as
`DIFY_AGENT_PROVIDER` and `DIFY_AGENT_MODEL_NAME` in `dify-agent/.env`.
The shape of `DIFY_AGENT_MODEL_CREDENTIALS_JSON` depends on the selected plugin
provider's credential schema. The `{"api_key":"..."}` value above is only an
OpenAI-style example.
## Troubleshooting
@ -221,7 +263,6 @@ If the run fails, check these items first:
2. The Dify Agent server is listening on `127.0.0.1:8000`.
3. `DIFY_AGENT_PLUGIN_DAEMON_URL` points to the correct plugin daemon.
4. `DIFY_AGENT_PLUGIN_DAEMON_API_KEY` matches the plugin daemon server key.
5. `PLUGIN_ID`, `MODEL_PROVIDER`, and `MODEL_NAME` in the client example match
the corresponding values configured in `dify-agent/.env` and a provider
available through the plugin daemon.
6. `MODEL_CREDENTIALS` matches that provider's credential schema.
5. `DIFY_AGENT_PLUGIN_ID`, `DIFY_AGENT_PROVIDER`, and
`DIFY_AGENT_MODEL_NAME` match a provider available through the plugin daemon.
6. `DIFY_AGENT_MODEL_CREDENTIALS_JSON` matches that provider's credential schema.

View File

@ -1,103 +0,0 @@
# History layer
The history layer stores pydantic-ai conversation history in the Agenton session
snapshot. Add it when a later run should resume the previous conversation.
The history layer is state-only: it contributes no prompt text, user prompt, or
tools, and it owns no live resources.
## Layer contract
| Property | Value |
| --- | --- |
| Reserved layer name | `history` |
| Type id | `pydantic_ai.history` |
| Config | none |
| Dependencies | none |
Use at most one history layer. It must be named `history` and must not declare
dependencies.
## Basic usage
```python {test="skip" lint="skip"}
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, RunLayerSpec
history_layer = RunLayerSpec(
name=DIFY_AGENT_HISTORY_LAYER_ID,
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
)
```
Include this layer in the same composition as your prompt, plugin, and LLM
layers.
## Resume a conversation
Successful runs return a terminal event with both final output and a resumable
session snapshot:
```python {test="skip" lint="skip"}
accepted = await client.create_run(request)
async for event in client.stream_events(accepted.run_id):
if event.type == "run_succeeded":
output = event.data.output
snapshot = event.data.session_snapshot
break
```
Pass `snapshot` to the next request and keep the same layer names and order:
```python {test="skip" lint="skip"}
next_request = CreateRunRequest(
composition=composition_with_the_same_layer_names_and_order,
session_snapshot=snapshot,
)
```
`CreateRunRequest.on_exit` defaults to suspending layers, which makes the
terminal snapshot resumable. Keep that default for normal memory flows.
## What gets stored
Dify Agent handles memory conservatively:
1. Current system prompts are rendered into temporary `message_history` before
stored history.
2. Stored history is then sent to the model.
3. Current user prompts are sent after the stored history.
4. Only newly produced pydantic-ai messages are appended after a successful run.
5. Current system prompts are not persisted into the history layer.
6. Failed runs emit `run_failed` and do not return a success snapshot to resume.
## Persist snapshots outside the client process
Session snapshots are Pydantic models and can be saved as JSON:
```python {test="skip" lint="skip"}
from pathlib import Path
from agenton.compositor import CompositorSessionSnapshot
snapshot_path = Path("session_snapshot.json")
snapshot_path.write_text(snapshot.model_dump_json(), encoding="utf-8")
restored_snapshot = CompositorSessionSnapshot.model_validate_json(
snapshot_path.read_text(encoding="utf-8")
)
```
Always restore snapshots with the same layer names and order that produced them.
## Troubleshooting
| Symptom | What to check |
| --- | --- |
| `must use reserved layer name 'history'` | Rename the layer to `history`. |
| `does not support dependencies` | Remove `deps` from the history layer. |
| Resume fails with snapshot lifecycle errors | Use the success snapshot from `run_succeeded` and keep layer names/order unchanged. |
| System prompts appear missing from saved memory | This is expected; current system prompts are temporary and are not persisted. |

View File

@ -1,59 +0,0 @@
# Plugin layer
The plugin layer carries Dify plugin daemon identity for a run. It identifies the
tenant, plugin, and optional user context; server settings provide the plugin
daemon URL and API key.
Use it together with a [plugin LLM layer](../plugin-llm-layer/index.md). The LLM
layer depends on this layer to reach the plugin daemon.
## Config fields
| Field | Type | Meaning |
| --- | --- | --- |
| `tenant_id` | `str` | Dify tenant/workspace id used when calling the plugin daemon. |
| `plugin_id` | `str` | Plugin id, for example `langgenius/openai`. |
| `user_id` | `str \| None` | Optional end-user id passed through to the plugin daemon. |
The plugin layer type id is `dify.plugin`.
## Basic usage
```python {test="skip" lint="skip"}
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig
from dify_agent.protocol import RunLayerSpec
plugin_layer = RunLayerSpec(
name="plugin",
type=DIFY_PLUGIN_LAYER_TYPE_ID,
config=DifyPluginLayerConfig(
tenant_id="replace-with-tenant-id",
plugin_id="langgenius/openai",
user_id="replace-with-user-id",
),
)
```
If you do not need a user id, omit `user_id` or pass `None`.
## Server-side settings
The plugin layer config does not include daemon transport settings. Configure
these on the Dify Agent server instead:
```env
DIFY_AGENT_PLUGIN_DAEMON_URL=http://localhost:5002
DIFY_AGENT_PLUGIN_DAEMON_API_KEY=replace-with-plugin-daemon-server-key
```
This keeps server credentials out of client-submitted layer config and out of
session snapshots.
## Notes
- The plugin layer does not open, cache, close, or snapshot HTTP clients.
- `plugin_id` selects the plugin package. The business model provider and model
name belong to the plugin LLM layer, not this layer.
- The conventional layer name is `plugin`. If you use another name, point the LLM
layer dependency at that name.

View File

@ -1,102 +0,0 @@
# Plugin LLM layer
The plugin LLM layer selects the model provider, model name, provider credentials,
and optional model settings for the current run. Dify Agent reads the model from
the reserved layer name `llm`.
It must depend on a [plugin layer](../plugin-layer/index.md), because the plugin
layer supplies the daemon identity and transport context.
## Config fields
| Field | Type | Meaning |
| --- | --- | --- |
| `model_provider` | `str` | Provider name inside the selected plugin. Use the value of `DIFY_AGENT_PROVIDER` from `dify-agent/.env`. |
| `model` | `str` | Model name. Use the value of `DIFY_AGENT_MODEL_NAME` from `dify-agent/.env`. |
| `credentials` | `dict[str, str \| int \| float \| bool \| None]` | Provider-specific credential object. |
| `model_settings` | `ModelSettings \| None` | Optional pydantic-ai model settings. |
The plugin LLM layer type id is `dify.plugin.llm`.
## Basic usage
```python {test="skip" lint="skip"}
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LLM_LAYER_TYPE_ID, DifyPluginLLMLayerConfig
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, RunLayerSpec
MODEL_PROVIDER = "replace-with-provider-from-dify-agent-env"
MODEL_NAME = "replace-with-model-from-dify-agent-env"
llm_layer = RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
model_provider=MODEL_PROVIDER,
model=MODEL_NAME,
credentials={"api_key": "replace-with-provider-key"},
),
)
```
`deps={"plugin": "plugin"}` means: bind the LLM layer's dependency field named
`plugin` to the composition layer named `plugin`.
Set `MODEL_PROVIDER` and `MODEL_NAME` to the same values as
`DIFY_AGENT_PROVIDER` and `DIFY_AGENT_MODEL_NAME` in `dify-agent/.env`.
## Complete minimal model composition
Most runs include a prompt, plugin context, and LLM layer:
```python {test="skip" lint="skip"}
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DifyPluginLLMLayerConfig,
DifyPluginLayerConfig,
)
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID, RunComposition, RunLayerSpec
MODEL_PROVIDER = "replace-with-provider-from-dify-agent-env"
MODEL_NAME = "replace-with-model-from-dify-agent-env"
composition = RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type=PLAIN_PROMPT_LAYER_TYPE_ID,
config=PromptLayerConfig(prefix="You are concise.", user="Say hello."),
),
RunLayerSpec(
name="plugin",
type=DIFY_PLUGIN_LAYER_TYPE_ID,
config=DifyPluginLayerConfig(
tenant_id="replace-with-tenant-id",
plugin_id="langgenius/openai",
),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
model_provider=MODEL_PROVIDER,
model=MODEL_NAME,
credentials={"api_key": "replace-with-provider-key"},
),
),
]
)
```
## Notes
- The model layer must use the reserved name `llm` (`DIFY_AGENT_MODEL_LAYER_ID`).
- Credential shape depends on the selected plugin provider; the OpenAI-style
`api_key` field above is only an example.
- Client-submitted model credentials remain in the scheduled request memory and
are not part of run records or session snapshots.

View File

@ -1,72 +0,0 @@
# Prompt layer
The prompt layer provides the current run's system and user prompt fragments. In
Dify Agent request bodies it is a regular `RunLayerSpec` with type id
`plain.prompt`.
Use it for:
- system instructions that should be sent on this run
- the current user input
- optional suffix system instructions
## Config fields
| Field | Type | Meaning |
| --- | --- | --- |
| `prefix` | `str` or `list[str]` | System prompt fragments collected before other prompt content. |
| `user` | `str` or `list[str]` | Current user-message fragments for the run. |
| `suffix` | `str` or `list[str]` | System prompt fragments collected after prefix content. |
All fields default to an empty list. Dify Agent rejects a create-run request when
the effective user prompt is empty or whitespace-only.
## Basic usage
```python {test="skip" lint="skip"}
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from dify_agent.protocol import RunLayerSpec
prompt_layer = RunLayerSpec(
name="prompt",
type=PLAIN_PROMPT_LAYER_TYPE_ID,
config=PromptLayerConfig(
prefix="You are a concise assistant.",
user="Summarize the incident in one paragraph.",
),
)
```
## Multiple prompt fragments
Use lists when the caller wants to keep fragments separate while still sending one
run:
```python {test="skip" lint="skip"}
prompt_layer = RunLayerSpec(
name="prompt",
type=PLAIN_PROMPT_LAYER_TYPE_ID,
config=PromptLayerConfig(
prefix=[
"You are an incident response assistant.",
"Prefer concrete mitigation steps.",
],
user=[
"Database latency is elevated.",
"Return the likely severity and next actions.",
],
suffix="Do not invent metrics that are not provided.",
),
)
```
## Notes
- The run API does not accept a top-level `user_prompt`; submit user input through
a prompt layer.
- Prompt layer names are not reserved by the runtime, but `prompt` is the
recommended conventional name.
- When a [history layer](../history-layer/index.md) is present, current system
prompts are sent as a temporary prefix before stored history and are not saved
into memory.

View File

@ -1,101 +0,0 @@
# Structured output layer
The structured output layer makes the final answer follow a caller-provided JSON
Schema. Add it when the client needs a JSON object instead of plain text.
When present, Dify Agent exposes the schema to the model as a structured-output
tool and validates the model response against the same schema.
## Layer contract
| Property | Value |
| --- | --- |
| Reserved layer name | `output` |
| Type id | `dify.output` |
| Config | `DifyOutputLayerConfig` |
| Dependencies | none |
Use at most one structured output layer. It must be named `output`.
## Config fields
| Field | Type | Meaning |
| --- | --- | --- |
| `json_schema` | `dict[str, JsonValue]` | Top-level object JSON Schema for the final answer. |
| `description` | `str \| None` | Optional model-facing tool description. |
| `strict` | `bool \| None` | Optional strictness flag passed to the output tool. |
The structured-output tool name is fixed to `final_output`.
## Basic usage
```python {test="skip" lint="skip"}
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import DIFY_AGENT_OUTPUT_LAYER_ID, RunLayerSpec
output_layer = RunLayerSpec(
name=DIFY_AGENT_OUTPUT_LAYER_ID,
type=DIFY_OUTPUT_LAYER_TYPE_ID,
config=DifyOutputLayerConfig(
description="Structured incident summary returned by the agent.",
strict=True,
json_schema={
"type": "object",
"properties": {
"title": {"type": "string"},
"severity": {"type": "string", "enum": ["low", "medium", "high"]},
"actions": {"type": "array", "items": {"type": "string"}},
},
"required": ["title", "severity", "actions"],
"additionalProperties": False,
},
),
)
```
On success, the terminal event contains the validated JSON-safe object:
```python {test="skip" lint="skip"}
async for event in client.stream_events(run_id):
if event.type == "run_succeeded":
structured_output = event.data.output
```
If the `output` layer is omitted, Dify Agent keeps the default plain text output
contract.
## Schema limits
The first structured-output version supports a practical subset of JSON Schema:
- the top-level schema must be an object (`"type": "object"`)
- the model-facing structured-output tool name is always `final_output`
- remote `$ref` values are not supported
- local refs are supported only under `#/$defs/...`
- recursive `$defs` refs are not supported
- `$ref` values inside ordinary literal keywords such as `const`, `enum`,
`example`, and `examples` are treated as data, not schema refs
## Validation and retry behavior
The runtime builds a pydantic-ai output contract from the layer config. The same
contract exposes the model-facing schema and validates the returned object.
If the model returns an invalid object, pydantic-ai's normal output-validation
retry behavior applies. If retries are exhausted, the run ends with `run_failed`.
## Resuming runs with structured output
Session snapshots store layer runtime state, not output-layer config. If you
resume a run that uses structured output, include the same `output` layer again so
the runtime can rebuild the output contract.
## Troubleshooting
| Symptom | What to check |
| --- | --- |
| `must use reserved layer name 'output'` | Rename the layer to `output`. |
| Structured output falls back to text | Confirm the `output` layer is present and has type `dify.output`. |
| Run fails before model resolution | Validate the JSON Schema and `$ref` usage. |
| Resume loses structured output | Resubmit the same output layer; snapshots do not store the schema. |

View File

@ -15,13 +15,7 @@ nav:
- Examples: agenton/examples/index.md
- Dify Agent:
- Overview: dify-agent/index.md
- User Manual:
- Get Started: dify-agent/get-started/index.md
- Prompt Layer: dify-agent/user-manual/prompt-layer/index.md
- Plugin Layer: dify-agent/user-manual/plugin-layer/index.md
- Plugin LLM Layer: dify-agent/user-manual/plugin-llm-layer/index.md
- History Layer: dify-agent/user-manual/history-layer/index.md
- Structured Output Layer: dify-agent/user-manual/structured-output-layer/index.md
- Get Started: dify-agent/get-started/index.md
- Operations Guide: dify-agent/guide/index.md
- Run API: dify-agent/api/index.md
- Examples: dify-agent/examples/index.md

View File

@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.28.1",
"pydantic>=2.12.5,<3",
"pydantic>=2.12.5,<2.13",
"pydantic-ai-slim>=1.85.1",
"typing-extensions>=4.12.2",
]

View File

@ -4,16 +4,8 @@ from agenton_collections.layers.pydantic_ai.bridge import (
PydanticAIBridgeLayer,
PydanticAIBridgeLayerDeps,
)
from agenton_collections.layers.pydantic_ai.history import (
PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
PydanticAIHistoryLayer,
PydanticAIHistoryRuntimeState,
)
__all__ = [
"PydanticAIBridgeLayer",
"PydanticAIBridgeLayerDeps",
"PYDANTIC_AI_HISTORY_LAYER_TYPE_ID",
"PydanticAIHistoryLayer",
"PydanticAIHistoryRuntimeState",
]

View File

@ -1,68 +0,0 @@
"""Serializable pydantic-ai conversation history layer.
This layer keeps pydantic-ai ``ModelMessage`` history inside Agenton's
serializable ``runtime_state`` so compositor session snapshots can persist and
restore typed messages without any separate storage protocol. The layer is
intentionally state-only: it contributes no system prompts, user prompts, or
tools, and it owns no live resources. Integrations should read
``message_history`` before ``Agent.run(message_history=...)`` and then write
back only the history shape they intend to persist after success, for example
replacing with ``result.all_messages()`` or appending only
``result.new_messages()`` when temporary prompt prefixes must stay ephemeral.
"""
from collections.abc import Sequence
from typing import ClassVar, Final
from pydantic import BaseModel, ConfigDict, Field
from pydantic_ai.messages import ModelMessage
from agenton.layers import EmptyLayerConfig, NoLayerDeps, PydanticAILayer
PYDANTIC_AI_HISTORY_LAYER_TYPE_ID: Final[str] = "pydantic_ai.history"
class PydanticAIHistoryRuntimeState(BaseModel):
"""Serializable history state stored in Agenton session snapshots."""
messages: list[ModelMessage] = Field(default_factory=list)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True)
class PydanticAIHistoryLayer(
PydanticAILayer[NoLayerDeps, object, EmptyLayerConfig, PydanticAIHistoryRuntimeState]
):
"""State-only layer that stores pydantic-ai message history.
The mutable history lives only in ``runtime_state.messages``. Helper methods
always assign fresh lists instead of mutating the stored list in place so
Pydantic assignment validation continues to guard the serialized state.
"""
type_id: ClassVar[str | None] = PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
@property
def message_history(self) -> list[ModelMessage]:
"""Return a shallow copy of the stored message history."""
return list(self.runtime_state.messages)
def replace_messages(self, messages: Sequence[ModelMessage]) -> None:
"""Replace the stored history with a validated copy of ``messages``."""
self.runtime_state.messages = list(messages)
def append_messages(self, messages: Sequence[ModelMessage]) -> None:
"""Append ``messages`` while keeping assignment validation on write."""
self.runtime_state.messages = [*self.runtime_state.messages, *messages]
def clear(self) -> None:
"""Remove all stored history messages."""
self.runtime_state.messages = []
__all__ = [
"PYDANTIC_AI_HISTORY_LAYER_TYPE_ID",
"PydanticAIHistoryLayer",
"PydanticAIHistoryRuntimeState",
]

View File

@ -8,6 +8,7 @@ imports do not pull in server execution code.
from __future__ import annotations
import re
from typing import ClassVar, Final
from pydantic import ConfigDict, JsonValue, field_validator
@ -16,6 +17,7 @@ from agenton.layers import LayerConfig
DIFY_OUTPUT_LAYER_TYPE_ID: Final[str] = "dify.output"
_OUTPUT_TOOL_NAME_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
class DifyOutputLayerConfig(LayerConfig):
@ -28,13 +30,15 @@ class DifyOutputLayerConfig(LayerConfig):
schemas plus local ``#/$defs/...`` references so the same caller-provided
schema can drive both runtime validation and model-facing tool exposure; the
exposure copy may inline supported ``$defs`` refs as needed for the
Pydantic/Pydantic AI integration. The structured-output tool name and schema
title exposed to pydantic-ai are fixed to ``final_output`` so callers only
control the JSON Schema itself plus any optional description/strictness
metadata.
Pydantic/Pydantic AI integration. ``name`` becomes the structured-output
tool name exposed to pydantic-ai, defaults to ``final_result``, and must be
1-64 ASCII letters, numbers, underscores, or hyphens so downstream model
providers accept it consistently. ``description`` and ``strict`` are passed
through to the generated structured-output tool definition.
"""
json_schema: dict[str, JsonValue]
name: str = "final_result"
description: str | None = None
strict: bool | None = None
@ -47,4 +51,12 @@ class DifyOutputLayerConfig(LayerConfig):
raise ValueError("Schema must declare an object output.")
return value
@field_validator("name")
@classmethod
def _ensure_safe_tool_name(cls, value: str) -> str:
if not _OUTPUT_TOOL_NAME_PATTERN.fullmatch(value):
raise ValueError("name must be 1-64 characters of letters, numbers, underscores, or hyphens.")
return value
__all__ = ["DIFY_OUTPUT_LAYER_TYPE_ID", "DifyOutputLayerConfig"]

View File

@ -34,8 +34,6 @@ from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
from dify_agent.layers.output.configs import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
_FINAL_OUTPUT_TOOL_NAME: Final[str] = "final_output"
_VALIDATED_OUTPUT_TYPE_NAME: Final[str] = f"DifyValidatedOutput_{_FINAL_OUTPUT_TOOL_NAME}"
_LOCAL_DEFS_REF_PREFIX: Final[str] = "#/$defs/"
_NON_SCHEMA_VALUE_KEYWORDS: Final[frozenset[str]] = frozenset({"const", "default", "enum", "example", "examples"})
@ -73,9 +71,7 @@ class DifyOutputLayer(PlainLayer[NoLayerDeps, DifyOutputLayerConfig, EmptyRuntim
runtime validation inside the same dynamically generated dict-like type.
First-version support is intentionally limited to top-level object JSON
Schemas so the same schema can be validated with ``jsonschema`` and then
exposed to Pydantic AI without any wrapper/unwrapper translation. The
public tool name and exposed schema title are always ``final_output`` so
providers see one stable structured-output contract shape.
exposed to Pydantic AI without any wrapper/unwrapper translation.
Raises:
ValueError: If the JSON Schema is invalid, contains non-local
@ -86,6 +82,7 @@ class DifyOutputLayer(PlainLayer[NoLayerDeps, DifyOutputLayerConfig, EmptyRuntim
_reject_non_local_refs(user_schema)
validated_output_type = _build_validated_output_type(
user_schema,
name=self.config.name,
description=self.config.description,
)
@ -94,7 +91,7 @@ class DifyOutputLayer(PlainLayer[NoLayerDeps, DifyOutputLayerConfig, EmptyRuntim
OutputSpec[object],
ToolOutput(
validated_output_type,
name=_FINAL_OUTPUT_TOOL_NAME,
name=self.config.name,
strict=self.config.strict,
),
),
@ -114,16 +111,18 @@ def _build_json_schema_validator(schema: dict[str, JsonValue]) -> JsonSchemaVali
def _build_validated_output_type(
schema: dict[str, JsonValue],
*,
name: str,
description: str | None,
) -> type[dict[str, object]]:
"""Create a dict-like output type with custom JSON schema and validation hooks.
The generated type object is fresh per output layer config. Its Pydantic core
The generated type is unique per output layer config. Its Pydantic core
schema performs real ``jsonschema`` validation, while its JSON schema hook
exposes a model-facing schema that Pydantic AI can turn into an output tool.
"""
validator = _build_json_schema_validator(schema)
exposed_schema = _build_exposed_json_schema(schema, description=description)
exposed_schema = _build_exposed_json_schema(schema, name=name, description=description)
type_name = _build_output_type_name(name)
def _validate_output(value: dict[str, object]) -> object:
errors = sorted(validator.iter_errors(cast(JsonValue, value)), key=lambda error: _sort_error_path(error.path))
@ -166,13 +165,14 @@ def _build_validated_output_type(
"__get_pydantic_core_schema__": __get_pydantic_core_schema__,
"__get_pydantic_json_schema__": __get_pydantic_json_schema__,
}
validated_output_type = cast(type[dict[str, object]], type(_VALIDATED_OUTPUT_TYPE_NAME, (dict,), namespace))
validated_output_type = cast(type[dict[str, object]], type(type_name, (dict,), namespace))
return validated_output_type
def _build_exposed_json_schema(
schema: dict[str, JsonValue],
*,
name: str,
description: str | None,
) -> dict[str, JsonValue]:
"""Return the schema exposed to the model through Pydantic AI.
@ -183,10 +183,18 @@ def _build_exposed_json_schema(
attached.
"""
exposed_schema = _inline_local_defs_refs(schema)
exposed_schema["title"] = _FINAL_OUTPUT_TOOL_NAME
exposed_schema["title"] = name
if description is not None:
exposed_schema["description"] = description
return exposed_schema
def _build_output_type_name(name: str) -> str:
"""Return a deterministic debug-friendly class name for one output schema."""
sanitized = "".join(character if character.isalnum() else "_" for character in name).strip("_") or "final_result"
return f"DifyValidatedOutput_{sanitized}"
def _reject_non_local_refs(schema: JsonValue) -> None:
"""Reject references that would require external fetching or non-local state.

View File

@ -1,7 +1,6 @@
"""Public protocol exports shared by the Dify Agent server and clients."""
from .schemas import (
DIFY_AGENT_HISTORY_LAYER_ID,
DIFY_AGENT_MODEL_LAYER_ID,
DIFY_AGENT_OUTPUT_LAYER_ID,
RUN_EVENT_ADAPTER,
@ -31,7 +30,6 @@ __all__ = [
"BaseRunEvent",
"CreateRunRequest",
"CreateRunResponse",
"DIFY_AGENT_HISTORY_LAYER_ID",
"DIFY_AGENT_MODEL_LAYER_ID",
"DIFY_AGENT_OUTPUT_LAYER_ID",
"EmptyRunEventData",

View File

@ -17,8 +17,7 @@ public ``id``/``run_id``/``type``/``data``/``created_at`` shape, while each
``type`` has a typed ``data`` model so OpenAPI, Redis replay, and clients parse
the same payload contract. Model/provider selection is part of the submitted
composition, not a top-level run field; the runtime reads the model layer named
by ``DIFY_AGENT_MODEL_LAYER_ID``, the optional history layer named by
``DIFY_AGENT_HISTORY_LAYER_ID``, and the optional structured output layer named
by ``DIFY_AGENT_MODEL_LAYER_ID`` and the optional structured output layer named
by ``DIFY_AGENT_OUTPUT_LAYER_ID``. Request-level ``on_exit`` signals decide
whether each active layer is suspended or deleted when the run exits, with
suspend as the default so successful terminal events can include resumable
@ -43,7 +42,6 @@ from agenton.layers import ExitIntent
DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm"
DIFY_AGENT_HISTORY_LAYER_ID: Final[str] = "history"
DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output"
RunStatus = Literal["running", "succeeded", "failed"]
RunEventType = Literal[
@ -106,10 +104,8 @@ class CreateRunRequest(BaseModel):
"""Request body for creating one async agent run.
Model/provider configuration must be supplied through the composition layer
named by ``DIFY_AGENT_MODEL_LAYER_ID``. Optional persisted conversation
history may be supplied through the composition layer named by
``DIFY_AGENT_HISTORY_LAYER_ID``. Structured output may be supplied through
the optional composition layer named by
named by ``DIFY_AGENT_MODEL_LAYER_ID``. Structured output may be supplied
through the optional composition layer named by
``DIFY_AGENT_OUTPUT_LAYER_ID``. ``on_exit`` defaults every active layer to
suspend so callers receive a resumable success snapshot unless they
explicitly request delete for one or more layers. Session snapshots do not
@ -258,7 +254,6 @@ __all__ = [
"BaseRunEvent",
"CreateRunRequest",
"CreateRunResponse",
"DIFY_AGENT_HISTORY_LAYER_ID",
"DIFY_AGENT_MODEL_LAYER_ID",
"DIFY_AGENT_OUTPUT_LAYER_ID",
"EmptyRunEventData",

View File

@ -2,10 +2,10 @@
The run request carries model/provider selection in the layer graph. This helper
keeps Agent construction details out of ``AgentRunRunner`` while accepting an
already resolved Pydantic AI model from the configured model layer. Tool values
arriving here are already transformed by Agenton's
``PYDANTIC_AI_TRANSFORMERS`` preset, while Dify system prompts are rendered into
temporary ``message_history`` before the call reaches this helper. The caller
already resolved Pydantic AI model from the configured model layer. Prompt and
tool values arriving here are already transformed by Agenton's
``PYDANTIC_AI_TRANSFORMERS`` preset; this module registers those pydantic-ai
objects without reimplementing plain/pydantic-ai conversion logic. The caller
also passes the already resolved ``output_type`` so legacy text output and the
optional JSON Schema output layer share the same ``Agent`` construction path.
"""
@ -18,12 +18,13 @@ from pydantic_ai.messages import UserContent
from pydantic_ai.models import Model
from pydantic_ai.output import OutputSpec
from agenton.layers.types import PydanticAITool
from agenton.layers.types import PydanticAIPrompt, PydanticAITool
def create_agent(
model: Model[Any],
*,
system_prompts: Sequence[PydanticAIPrompt[object]],
tools: Sequence[PydanticAITool[object]],
output_type: OutputSpec[object] = str,
) -> Agent[None, object]:
@ -35,7 +36,10 @@ def create_agent(
carries the Pydantic hooks needed for schema exposure and runtime validation,
so agent construction does not need to register a separate validator.
"""
return cast(Agent[None, object], Agent(model, output_type=output_type, tools=tools))
agent = cast(Agent[None, object], Agent(model, output_type=output_type, tools=tools))
for prompt in system_prompts:
_ = agent.system_prompt(cast(Any, prompt))
return agent
def normalize_user_input(user_prompts: Sequence[UserContent]) -> str | Sequence[UserContent]:

View File

@ -1,13 +1,12 @@
"""Safe Agenton compositor construction for API-submitted configs.
Only explicitly allowed provider type ids are constructible here. The default
provider set contains prompt layers, the optional pydantic-ai history layer, the
state-free Dify structured output layer, plus Dify plugin LLM layers. Public
DTOs provide tenant/plugin/model data, while server-only plugin daemon settings
are injected through the provider factory for ``DifyPluginLayer``. The resulting
``Compositor`` remains Agenton state-only: live resources such as the plugin
daemon HTTP client are supplied later by the runtime and never enter providers,
layers, or session snapshots.
provider set contains prompt layers, the state-free Dify structured output
layer, plus Dify plugin LLM layers. Public DTOs provide tenant/plugin/model
data, while server-only plugin daemon settings are injected through the provider
factory for ``DifyPluginLayer``. The resulting ``Compositor`` remains Agenton
state-only: live resources such as the plugin daemon HTTP client are supplied
later by the runtime and never enter providers, layers, or session snapshots.
"""
from collections.abc import Mapping, Sequence
@ -17,7 +16,6 @@ from pydantic_ai.messages import UserContent
from agenton.compositor import Compositor, CompositorConfig, LayerProvider, LayerProviderInput
from agenton.layers.types import AllPromptTypes, AllToolTypes, AllUserPromptTypes, PydanticAIPrompt, PydanticAITool
from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer
from agenton_collections.layers.plain.basic import PromptLayer
from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMERS
from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig
@ -37,7 +35,6 @@ def create_default_layer_providers(
"""Return the server provider set of safe config-constructible layers."""
return (
LayerProvider.from_layer_type(PromptLayer),
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
LayerProvider.from_layer_type(DifyOutputLayer),
LayerProvider.from_factory(
layer_type=DifyPluginLayer,

View File

@ -1,133 +0,0 @@
"""Helpers for optional Dify Agent history-layer integration.
Dify Agent keeps pydantic-ai conversation history as an optional Agenton layer
named ``history``. The runner always injects the current Dify system prompt via
temporary ``message_history`` instead of ``Agent.system_prompt(...)`` so the
model sees ``current system prompt -> stored history -> current user prompt``
even when persisted history is present. Only zero-argument system prompt
callables are supported here because the prompts are rendered outside
pydantic-ai's normal run context; this matches Dify's current plain-prompt
compositions and fails fast for unsupported context-dependent prompt shapes.
"""
from __future__ import annotations
import inspect
from collections.abc import Awaitable, Callable, Sequence
from typing import Protocol, cast
from pydantic_ai.messages import ModelMessage, ModelRequest, SystemPromptPart
from agenton.layers.types import PydanticAIPrompt
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryLayer
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID
from dify_agent.protocol.schemas import RunComposition
class SupportsHistoryLayerLookup(Protocol):
"""Minimal entered-run surface needed by the history helper."""
def get_layer(self, name: str, layer_type: type[PydanticAIHistoryLayer]) -> PydanticAIHistoryLayer:
"""Return a typed layer instance or raise lookup/type errors."""
...
def validate_history_layer_composition(composition: RunComposition) -> None:
"""Reject unsupported public history-layer graph shapes."""
history_layers = [layer for layer in composition.layers if layer.type == PYDANTIC_AI_HISTORY_LAYER_TYPE_ID]
if not history_layers:
return
if len(history_layers) > 1:
names = ", ".join(layer.name for layer in history_layers)
raise ValueError(
f"Only one '{PYDANTIC_AI_HISTORY_LAYER_TYPE_ID}' layer is supported, named "
f"'{DIFY_AGENT_HISTORY_LAYER_ID}'. Found layers: {names}."
)
history_layer = history_layers[0]
if history_layer.name != DIFY_AGENT_HISTORY_LAYER_ID:
raise ValueError(
f"Layer type '{PYDANTIC_AI_HISTORY_LAYER_TYPE_ID}' must use reserved layer name "
f"'{DIFY_AGENT_HISTORY_LAYER_ID}', got '{history_layer.name}'."
)
if history_layer.deps:
dependency_names = ", ".join(sorted(history_layer.deps))
raise ValueError(
f"Layer type '{PYDANTIC_AI_HISTORY_LAYER_TYPE_ID}' does not support dependencies; "
f"got dependency keys: {dependency_names}."
)
def get_history_layer(run: SupportsHistoryLayerLookup) -> PydanticAIHistoryLayer | None:
"""Return the active history layer when the reserved slot is present."""
try:
return run.get_layer(DIFY_AGENT_HISTORY_LAYER_ID, PydanticAIHistoryLayer)
except KeyError:
return None
async def build_run_message_history(
*,
system_prompts: Sequence[PydanticAIPrompt[object]],
stored_history: Sequence[ModelMessage],
) -> list[ModelMessage] | None:
"""Build temporary pydantic-ai history for one Dify Agent loop.
Current system prompts are rendered first into one transient
``ModelRequest`` prefix, followed by any already stored history messages.
When both inputs are empty, the helper returns ``None`` so callers can omit
the ``message_history`` argument entirely and preserve pydantic-ai's empty
history behavior.
"""
rendered_system_parts: list[SystemPromptPart] = []
for prompt in system_prompts:
prompt_text = await _render_system_prompt(prompt)
if prompt_text is None:
continue
rendered_system_parts.append(SystemPromptPart(content=prompt_text))
message_history: list[ModelMessage] = []
if rendered_system_parts:
message_history.append(ModelRequest(parts=rendered_system_parts))
message_history.extend(stored_history)
return message_history or None
def append_successful_run_history(
history_layer: PydanticAIHistoryLayer | None,
new_messages: Sequence[ModelMessage],
) -> None:
"""Append only newly produced pydantic-ai messages after successful runs."""
if history_layer is None or not new_messages:
return
history_layer.append_messages(new_messages)
async def _render_system_prompt(prompt: PydanticAIPrompt[object]) -> str | None:
signature = inspect.signature(prompt)
if signature.parameters:
raise ValueError(
"Dify Agent runtime currently supports only zero-argument system prompts when rendering temporary "
"message history."
)
prompt_without_context = cast(Callable[[], str | None | Awaitable[str | None]], prompt)
prompt_value = prompt_without_context()
if inspect.isawaitable(prompt_value):
prompt_value = await prompt_value
if prompt_value is None:
return None
if not isinstance(prompt_value, str):
raise TypeError(f"System prompt callables must return str | None, got '{type(prompt_value).__name__}'.")
return prompt_value
__all__ = [
"SupportsHistoryLayerLookup",
"append_successful_run_history",
"build_run_message_history",
"get_history_layer",
"validate_history_layer_composition",
]

View File

@ -6,11 +6,10 @@ task registry. Redis remains the durable source for status and event streams, bu
there is no Redis job queue or cross-process handoff. If the process crashes,
currently active runs are lost until an external operator marks or retries them.
Create-run validation enters a lightweight Agenton run before persistence so the
same transformed user prompts, temporary system-prompt history assembly,
optional structured output contract, and top-level ``on_exit`` policy used by
execution are checked without relying on removed session/control APIs; Dify's
default layers keep lifecycle hooks side-effect free so this validation does not
open plugin daemon clients.
same transformed user prompts, optional structured output contract, and
top-level ``on_exit`` policy used by execution are checked without relying on
removed session/control APIs; Dify's default layers keep lifecycle hooks
side-effect free so this validation does not open plugin daemon clients.
"""
import asyncio
@ -25,7 +24,6 @@ from dify_agent.protocol.schemas import CreateRunRequest, normalize_composition
from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error
from dify_agent.runtime.compositor_factory import build_pydantic_ai_compositor, create_default_layer_providers
from dify_agent.runtime.event_sink import RunEventSink, emit_run_failed
from dify_agent.runtime.history import build_run_message_history, get_history_layer, validate_history_layer_composition
from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals
from dify_agent.runtime.output_type import resolve_run_output_contract, validate_output_layer_composition
from dify_agent.runtime.runner import AgentRunRunner
@ -171,20 +169,18 @@ async def validate_run_request(
) -> None:
"""Validate create-run semantics that require an entered Agenton run.
This boundary rejects unsupported output/history-layer graph shapes, unknown
This boundary rejects unsupported output-layer graph shapes, unknown
``on_exit`` layer ids, effectively empty transformed user prompts, and known
enter-time snapshot lifecycle errors before the scheduler persists a run
record. It also exercises provider config validation, temporary
system-prompt history assembly, structured output contract construction, and
snapshot hydration without touching external services because Dify plugin
daemon clients are owned by the FastAPI lifespan, not Agenton lifecycle
hooks.
record. It also exercises provider config validation, structured output
contract construction, and snapshot hydration without touching external
services because Dify plugin daemon clients are owned by the FastAPI
lifespan, not Agenton lifecycle hooks.
"""
resolved_layer_providers = layer_providers if layer_providers is not None else create_default_layer_providers()
entered_run = False
try:
validate_output_layer_composition(request.composition)
validate_history_layer_composition(request.composition)
graph_config, layer_configs = normalize_composition(request.composition)
compositor = build_pydantic_ai_compositor(
graph_config,
@ -194,11 +190,6 @@ async def validate_run_request(
async with compositor.enter(configs=layer_configs, session_snapshot=request.session_snapshot) as run:
entered_run = True
apply_layer_exit_signals(run, request.on_exit)
history_layer = get_history_layer(run)
_ = await build_run_message_history(
system_prompts=run.prompts,
stored_history=history_layer.message_history if history_layer is not None else (),
)
if not has_non_blank_user_prompt(run.user_prompts):
raise RunRequestValidationError(EMPTY_USER_PROMPTS_ERROR)
_ = resolve_run_output_contract(run)

View File

@ -2,22 +2,19 @@
The runner is storage-agnostic: it normalizes the public Dify composition into
Agenton's graph/config split, enters a fresh ``CompositorRun`` (or resumes one
from a snapshot), renders the current Dify system prompts into temporary
``message_history``, runs pydantic-ai with ``run.user_prompts`` as the current
user input, emits stream events, applies request-level ``on_exit`` signals, and
then publishes a terminal success or failure event. The Pydantic AI model is
resolved from the active Agenton layer named by ``DIFY_AGENT_MODEL_LAYER_ID``.
An optional history layer contributes stored message history only through
session state; successful runs append only ``result.new_messages()`` back into
that layer so current system prompts are not persisted. An optional structured
output layer named by ``DIFY_AGENT_OUTPUT_LAYER_ID`` is read after entry and
resolved into an output contract whose type both exposes the output schema to
the model and performs runtime JSON Schema validation through custom Pydantic
hooks. Invalid structured outputs therefore trigger Pydantic AI's normal
output-validation retry behavior before Dify Agent emits ``run_succeeded``.
Layers still never own the FastAPI lifespan-owned plugin daemon HTTP client.
Successful terminal events contain both the JSON-safe final output and session
snapshot; there are no separate output or snapshot events to correlate.
from a snapshot), runs pydantic-ai with ``run.user_prompts`` as the user input,
emits stream events, applies request-level ``on_exit`` signals, and then
publishes a terminal success or failure event. The Pydantic AI model is resolved
from the active Agenton layer named by ``DIFY_AGENT_MODEL_LAYER_ID``. An
optional structured output layer named by ``DIFY_AGENT_OUTPUT_LAYER_ID`` is read
after entry and resolved into an output contract whose type both exposes the
output schema to the model and performs runtime JSON Schema validation through
custom Pydantic hooks. Invalid structured outputs therefore trigger Pydantic
AI's normal output-validation retry behavior before Dify Agent emits
``run_succeeded``. Layers still never own the FastAPI lifespan-owned plugin
daemon HTTP client. Successful terminal events contain both the JSON-safe final
output and session snapshot; there are no separate output or snapshot events to
correlate.
"""
from collections.abc import AsyncIterable
@ -40,12 +37,6 @@ from dify_agent.runtime.event_sink import (
emit_run_started,
emit_run_succeeded,
)
from dify_agent.runtime.history import (
append_successful_run_history,
build_run_message_history,
get_history_layer,
validate_history_layer_composition,
)
from dify_agent.runtime.layer_exit_signals import apply_layer_exit_signals, validate_layer_exit_signals
from dify_agent.runtime.output_type import resolve_run_output_contract, validate_output_layer_composition
from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt
@ -109,18 +100,17 @@ class AgentRunRunner:
Known input-shaped Agenton enter-time runtime errors, such as trying to
resume a ``CLOSED`` snapshot layer, are normalized to
``AgentRunValidationError``. Output/history-layer graph invariants are
validated from the public composition before entering Agenton so
misnamed or extra reserved layers never silently degrade. Later runtime
failures still propagate as execution errors so they become terminal
failed runs rather than client validation responses. Structured output
uses a resolved contract whose type itself encodes both the model-facing
schema and the runtime validation hooks, so invalid model outputs can be
corrected before Dify Agent emits success.
``AgentRunValidationError``. Output-layer graph invariants are validated
from the public composition before entering Agenton so misnamed or extra
``dify.output`` layers never silently degrade to text output. Later
runtime failures still propagate as execution errors so they become
terminal failed runs rather than client validation responses. Structured
output uses a resolved contract whose type itself encodes both the
model-facing schema and the runtime validation hooks, so invalid model
outputs can be corrected before Dify Agent emits success.
"""
try:
validate_output_layer_composition(self.request.composition)
validate_history_layer_composition(self.request.composition)
graph_config, layer_configs = normalize_composition(self.request.composition)
compositor = build_pydantic_ai_compositor(graph_config, providers=self.layer_providers)
validate_layer_exit_signals(compositor, self.request.on_exit)
@ -142,11 +132,6 @@ class AgentRunRunner:
try:
output_contract = resolve_run_output_contract(run)
history_layer = get_history_layer(run)
message_history = await build_run_message_history(
system_prompts=run.prompts,
stored_history=history_layer.message_history if history_layer is not None else (),
)
llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer)
model = llm_layer.get_model(http_client=self.plugin_daemon_http_client)
except (KeyError, TypeError, RuntimeError, ValueError) as exc:
@ -154,16 +139,12 @@ class AgentRunRunner:
agent = create_agent(
model,
system_prompts=run.prompts,
tools=run.tools,
output_type=output_contract.output_type,
)
result = await agent.run(
normalize_user_input(user_prompts),
message_history=message_history,
event_stream_handler=handle_events,
)
result = await agent.run(normalize_user_input(user_prompts), event_stream_handler=handle_events)
output = _serialize_agent_output(result.output)
append_successful_run_history(history_layer, result.new_messages())
except RuntimeError as exc:
if not entered_run and is_agenton_enter_validation_runtime_error(exc):
raise AgentRunValidationError(str(exc)) from exc

View File

@ -29,11 +29,8 @@ class RedisRunStore(RunEventSink):
"""Async Redis implementation for run records and event logs.
``run_retention_seconds`` is applied to both the run record key and the
per-run Redis stream. Event writes run ``XADD`` and both TTL refreshes in one
Redis transaction so a newly created stream is not left without expiration if
the client is interrupted between commands. Event writes also refresh the
record TTL so long-running runs that keep producing events do not lose their
status record mid-run.
per-run Redis stream. Event writes also refresh the record TTL so long-running
runs that keep producing events do not lose their status record mid-run.
"""
redis: Redis
@ -84,18 +81,15 @@ class RedisRunStore(RunEventSink):
)
async def append_event(self, event: RunEvent) -> str:
"""Append an event JSON payload to the run's Redis stream with TTLs."""
"""Append an event JSON payload to the run's Redis stream."""
events_key = run_events_key(self.prefix, event.run_id)
payload = RUN_EVENT_ADAPTER.dump_json(event, exclude={"id"}).decode()
async with self.redis.pipeline(transaction=True) as pipeline:
_ = pipeline.xadd(
events_key,
{"payload": payload},
)
_ = pipeline.expire(events_key, self.run_retention_seconds)
_ = pipeline.expire(run_record_key(self.prefix, event.run_id), self.run_retention_seconds)
results = cast(list[object], await pipeline.execute())
event_id = results[0]
event_id = await self.redis.xadd(
events_key,
{"payload": payload},
)
await self.redis.expire(events_key, self.run_retention_seconds)
await self.redis.expire(run_record_key(self.prefix, event.run_id), self.run_retention_seconds)
return event_id.decode() if isinstance(event_id, bytes) else str(event_id)
async def get_events(self, run_id: str, *, after: str = "0-0", limit: int = 100) -> RunEventsResponse:

View File

@ -1,96 +0,0 @@
import asyncio
from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse, TextPart, UserPromptPart
from agenton.compositor import Compositor, LayerNode
from agenton.layers import LifecycleState
from agenton_collections.layers.pydantic_ai import PydanticAIHistoryLayer, PydanticAIHistoryRuntimeState
def test_pydantic_ai_history_layer_starts_empty_and_contributes_no_prompts_or_tools() -> None:
layer = PydanticAIHistoryLayer()
assert layer.message_history == []
assert list(layer.prefix_prompts) == []
assert list(layer.suffix_prompts) == []
assert list(layer.user_prompts) == []
assert list(layer.tools) == []
def test_pydantic_ai_history_layer_replace_messages_saves_validated_copy() -> None:
layer = PydanticAIHistoryLayer()
messages = _sample_messages()
layer.replace_messages(messages)
borrowed_messages = layer.message_history
assert borrowed_messages == messages
assert borrowed_messages is not messages
messages.append(ModelResponse(parts=[TextPart(content="later")]))
assert layer.message_history != messages
def test_pydantic_ai_history_layer_append_messages_preserves_order_and_internal_state() -> None:
layer = PydanticAIHistoryLayer()
request, response = _sample_messages()
layer.replace_messages([request])
layer.append_messages((response,))
borrowed_messages = layer.message_history
borrowed_messages.clear()
assert layer.message_history == [request, response]
def test_pydantic_ai_history_layer_clear_removes_stored_messages() -> None:
layer = PydanticAIHistoryLayer()
layer.replace_messages(_sample_messages())
layer.clear()
assert layer.message_history == []
assert layer.runtime_state.messages == []
def test_pydantic_ai_history_runtime_state_round_trips_through_json_dump() -> None:
messages = _sample_messages()
runtime_state = PydanticAIHistoryRuntimeState(messages=messages)
dumped_state = runtime_state.model_dump(mode="json")
restored_state = PydanticAIHistoryRuntimeState.model_validate(dumped_state)
assert restored_state.messages == messages
assert isinstance(restored_state.messages[0], ModelRequest)
assert isinstance(restored_state.messages[1], ModelResponse)
def test_pydantic_ai_history_layer_messages_round_trip_through_session_snapshot() -> None:
compositor = Compositor([LayerNode("history", PydanticAIHistoryLayer)])
messages = _sample_messages()
async def scenario() -> None:
async with compositor.enter() as first_run:
history_layer = first_run.get_layer("history", PydanticAIHistoryLayer)
history_layer.replace_messages(messages)
first_run.suspend_on_exit()
assert first_run.session_snapshot is not None
assert first_run.session_snapshot.layers[0].lifecycle_state is LifecycleState.SUSPENDED
async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run:
history_layer = resumed_run.get_layer("history", PydanticAIHistoryLayer)
assert history_layer.message_history == messages
assert isinstance(history_layer.runtime_state.messages[0], ModelRequest)
assert isinstance(history_layer.runtime_state.messages[1], ModelResponse)
asyncio.run(scenario())
def _sample_messages() -> list[ModelMessage]:
return [
ModelRequest(parts=[UserPromptPart(content="Hello")]),
ModelResponse(parts=[TextPart(content="Hi there")]),
]

View File

@ -121,16 +121,11 @@ def test_output_package_exports_client_safe_config_symbols_only() -> None:
assert not hasattr(output_exports, "DifyOutputLayer")
def test_output_layer_config_accepts_valid_object_schema_without_public_tool_name() -> None:
def test_output_layer_config_accepts_valid_object_schema_and_defaults_name() -> None:
config = DifyOutputLayerConfig(json_schema=_json_schema())
assert DIFY_OUTPUT_LAYER_TYPE_ID == "dify.output"
assert hasattr(config, "name") is False
assert config.model_dump(mode="json") == {
"json_schema": _json_schema(),
"description": None,
"strict": None,
}
assert config.name == "final_result"
assert config.description is None
assert config.strict is None
@ -143,7 +138,7 @@ def test_output_layer_config_rejects_non_object_top_level_json_schema() -> None:
@pytest.mark.parametrize(
("payload", "message"),
[
({"json_schema": _json_schema(), "name": "bad name"}, "Extra inputs are not permitted"),
({"json_schema": _json_schema(), "name": "bad name"}, "letters, numbers, underscores, or hyphens"),
({"json_schema": _json_schema(), "unknown": True}, "Extra inputs are not permitted"),
],
)
@ -155,6 +150,7 @@ def test_output_layer_config_rejects_invalid_input(payload: dict[str, object], m
def test_output_layer_builds_validated_output_contract_for_object_schema() -> None:
config = DifyOutputLayerConfig(
json_schema=_json_schema(),
name="incident_summary",
description="Structured incident summary.",
strict=True,
)
@ -167,11 +163,11 @@ def test_output_layer_builds_validated_output_contract_for_object_schema() -> No
output_adapter = TypeAdapter(_validated_output_type(output_contract.output_type))
assert isinstance(output_type, ToolOutput)
assert output_type.name == "final_output"
assert output_type.name == "incident_summary"
assert output_type.description is None
assert output_type.strict is True
assert output_schema["type"] == "object"
assert output_schema["title"] == "final_output"
assert output_schema["title"] == "incident_summary"
assert output_schema["description"] == "Structured incident summary."
assert output_adapter.validate_python(valid_output) == valid_output
@ -210,7 +206,7 @@ def test_output_layer_rejects_non_defs_local_ref_in_direct_object_schema() -> No
def test_output_layer_keeps_local_defs_ref_working_in_direct_object_schema() -> None:
output_contract = DifyOutputLayer.from_config(
DifyOutputLayerConfig(json_schema=_object_local_defs_ref_schema())
DifyOutputLayerConfig(json_schema=_object_local_defs_ref_schema(), name="direct_defs_result")
).build_output_contract()
output_adapter = TypeAdapter(_validated_output_type(output_contract.output_type))
output_schema = output_adapter.json_schema()
@ -225,7 +221,7 @@ def test_output_layer_keeps_local_defs_ref_working_in_direct_object_schema() ->
},
},
"required": ["items"],
"title": "final_output",
"title": "direct_defs_result",
}
assert output_adapter.validate_python({"items": ["a", "b"]}) == {"items": ["a", "b"]}

View File

@ -8,7 +8,7 @@ from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptL
import dify_agent.protocol as protocol_exports
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID
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 import DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.schemas import (
RUN_EVENT_ADAPTER,
CreateRunRequest,
@ -63,7 +63,6 @@ def test_pydantic_ai_event_data_uses_agent_stream_event_model() -> None:
def test_create_run_request_rejects_old_compositor_payload_and_model_layer_id_is_public() -> None:
assert DIFY_AGENT_MODEL_LAYER_ID == "llm"
assert DIFY_AGENT_HISTORY_LAYER_ID == "history"
assert DIFY_AGENT_OUTPUT_LAYER_ID == "output"
with pytest.raises(ValidationError):
_ = CreateRunRequest.model_validate(

View File

@ -7,10 +7,9 @@ import pytest
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
from agenton.layers import ExitIntent, LifecycleState
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from agenton_collections.layers.plain import PromptLayerConfig
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol import DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.schemas import (
CreateRunRequest,
LayerExitSignals,
@ -192,6 +191,7 @@ def test_create_run_rejects_invalid_output_schema_before_persisting() -> None:
await scheduler.create_run(
_request(
output_config={
"name": "incident_summary",
"json_schema": _recursive_output_schema(),
}
)
@ -212,6 +212,7 @@ def test_create_run_rejects_remote_ref_output_schema_before_persisting() -> None
await scheduler.create_run(
_request(
output_config={
"name": "incident_summary",
"json_schema": {
"type": "object",
"properties": {
@ -237,6 +238,7 @@ def test_create_run_rejects_non_object_output_schema_before_persisting() -> None
await scheduler.create_run(
_request(
output_config={
"name": "incident_actions",
"json_schema": {
"type": "array",
"items": {"type": "string"},
@ -250,32 +252,6 @@ def test_create_run_rejects_non_object_output_schema_before_persisting() -> None
asyncio.run(scenario())
def test_create_run_rejects_public_output_tool_name_override_before_persisting() -> None:
async def scenario() -> None:
store = FakeStore()
async with httpx.AsyncClient() as client:
scheduler = RunScheduler(store=store, plugin_daemon_http_client=client)
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
await scheduler.create_run(
_request(
output_config={
"name": "incident_summary",
"json_schema": {
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
"additionalProperties": False,
},
}
)
)
assert store.records == {}
asyncio.run(scenario())
def test_create_run_rejects_non_defs_local_ref_in_direct_object_schema_before_persisting() -> None:
async def scenario() -> None:
store = FakeStore()
@ -286,6 +262,7 @@ def test_create_run_rejects_non_defs_local_ref_in_direct_object_schema_before_pe
await scheduler.create_run(
_request(
output_config={
"name": "incident_summary",
"json_schema": {
"type": "object",
"properties": {
@ -449,78 +426,6 @@ def test_validate_run_request_rejects_misnamed_output_layer_before_provider_chec
asyncio.run(scenario())
def test_validate_run_request_accepts_reserved_history_layer() -> None:
async def scenario() -> None:
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
]
)
)
await validate_run_request(request)
asyncio.run(scenario())
def test_validate_run_request_rejects_misnamed_history_layer_before_provider_checks() -> None:
async def scenario() -> None:
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
RunLayerSpec(name="chat-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
]
)
)
with pytest.raises(RunRequestValidationError, match="must use reserved layer name 'history'"):
await validate_run_request(request, layer_providers=())
asyncio.run(scenario())
def test_validate_run_request_rejects_multiple_history_layers_before_provider_checks() -> None:
async def scenario() -> None:
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
RunLayerSpec(name="secondary-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
]
)
)
with pytest.raises(RunRequestValidationError, match="Only one 'pydantic_ai.history' layer is supported"):
await validate_run_request(request, layer_providers=())
asyncio.run(scenario())
def test_validate_run_request_rejects_history_layer_dependencies_before_provider_checks() -> None:
async def scenario() -> None:
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(name="prompt", type="plain.prompt", config=PromptLayerConfig(user="hello")),
RunLayerSpec(
name=DIFY_AGENT_HISTORY_LAYER_ID,
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
deps={"prompt": "prompt"},
),
]
)
)
with pytest.raises(RunRequestValidationError, match="does not support dependencies"):
await validate_run_request(request, layer_providers=())
asyncio.run(scenario())
def test_create_run_rejects_unknown_layer_exit_signal_before_persisting() -> None:
async def scenario() -> None:
store = FakeStore()

View File

@ -5,19 +5,18 @@ from typing import Any
import httpx
import pytest
from pydantic_ai.exceptions import UnexpectedModelBehavior
from pydantic_ai.messages import ModelMessage, ModelRequest, ModelResponse, SystemPromptPart, TextPart, ToolCallPart, UserPromptPart
from pydantic_ai.messages import ModelMessage, ModelResponse, ToolCallPart
from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.models.test import TestModel
from pydantic_ai.settings import ModelSettings
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
from agenton.layers import ExitIntent, LifecycleState
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState
from agenton_collections.layers.plain import PromptLayerConfig
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
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 import DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.schemas import (
CreateRunRequest,
LayerExitSignals,
@ -32,7 +31,6 @@ from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError
def _request(
user: str | list[str] = "hello",
*,
include_history: bool = False,
llm_layer_name: str = DIFY_AGENT_MODEL_LAYER_ID,
plugin_layer_name: str = "plugin",
on_exit: LayerExitSignals | None = None,
@ -44,11 +42,6 @@ def _request(
type="plain.prompt",
config=PromptLayerConfig(prefix="system", user=user),
),
*(
[RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID)]
if include_history
else []
),
RunLayerSpec(
name=plugin_layer_name,
type="dify.plugin",
@ -129,58 +122,6 @@ class SequenceOutputTestModel(TestModel):
)
class RecordingTestModel(TestModel):
seen_requests: list[list[ModelMessage]]
failure: Exception | None
def __init__(self, *, custom_output_text: str = "done", failure: Exception | None = None) -> None:
super().__init__(call_tools=[], custom_output_text=custom_output_text)
self.seen_requests = []
self.failure = failure
def _request(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
self.seen_requests.append(list(messages))
if self.failure is not None:
raise self.failure
return super()._request(messages, model_settings, model_request_parameters)
def _history_session_snapshot(
messages: list[ModelMessage],
*,
include_output: bool = False,
) -> CompositorSessionSnapshot:
layers = [
LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
LayerSessionSnapshot(
name=DIFY_AGENT_HISTORY_LAYER_ID,
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state=PydanticAIHistoryRuntimeState(messages=messages).model_dump(mode="json"),
),
LayerSessionSnapshot(name="plugin", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
LayerSessionSnapshot(name=DIFY_AGENT_MODEL_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
]
if include_output:
layers.append(
LayerSessionSnapshot(name=DIFY_AGENT_OUTPUT_LAYER_ID, lifecycle_state=LifecycleState.SUSPENDED, runtime_state={})
)
return CompositorSessionSnapshot(layers=layers)
def _history_messages_from_snapshot(snapshot: CompositorSessionSnapshot) -> list[ModelMessage]:
history_snapshot = next(layer for layer in snapshot.layers if layer.name == DIFY_AGENT_HISTORY_LAYER_ID)
return PydanticAIHistoryRuntimeState.model_validate(history_snapshot.runtime_state).messages
def _flatten_message_parts(messages: list[ModelMessage]) -> list[object]:
return [part for message in messages for part in message.parts]
def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
seen_clients: list[httpx.AsyncClient] = []
@ -229,172 +170,6 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
assert sink.statuses["run-1"] == "succeeded"
def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None:
model = RecordingTestModel(custom_output_text="done")
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return model # pyright: ignore[reportReturnType]
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
await AgentRunRunner(
sink=sink,
request=_request("current user"),
run_id="run-no-history",
plugin_daemon_http_client=client,
).run()
asyncio.run(scenario())
request_parts = _flatten_message_parts(model.seen_requests[0])
assert isinstance(request_parts[0], SystemPromptPart)
assert request_parts[0].content == "system"
assert isinstance(request_parts[1], UserPromptPart)
assert request_parts[1].content == "current user"
terminal = sink.events["run-no-history"][-1]
assert isinstance(terminal, RunSucceededEvent)
assert [layer.name for layer in terminal.data.session_snapshot.layers] == ["prompt", "plugin", DIFY_AGENT_MODEL_LAYER_ID]
def test_runner_prepends_current_system_prompt_to_stored_history_and_appends_only_new_messages(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = RecordingTestModel(custom_output_text="done")
stored_history = [
ModelRequest(parts=[UserPromptPart(content="old user")]),
ModelResponse(parts=[TextPart(content="old assistant")]),
]
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return model # pyright: ignore[reportReturnType]
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
request = _request("current user", include_history=True)
request.session_snapshot = _history_session_snapshot(stored_history)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-history",
plugin_daemon_http_client=client,
).run()
asyncio.run(scenario())
request_parts = _flatten_message_parts(model.seen_requests[0])
assert isinstance(request_parts[0], SystemPromptPart)
assert request_parts[0].content == "system"
assert isinstance(request_parts[1], UserPromptPart)
assert request_parts[1].content == "old user"
assert isinstance(request_parts[2], TextPart)
assert request_parts[2].content == "old assistant"
assert isinstance(request_parts[3], UserPromptPart)
assert request_parts[3].content == "current user"
terminal = sink.events["run-history"][-1]
assert isinstance(terminal, RunSucceededEvent)
saved_history = _history_messages_from_snapshot(terminal.data.session_snapshot)
assert saved_history[:2] == stored_history
assert isinstance(saved_history[2], ModelRequest)
assert len(saved_history[2].parts) == 1
assert isinstance(saved_history[2].parts[0], UserPromptPart)
assert saved_history[2].parts[0].content == "current user"
assert isinstance(saved_history[3], ModelResponse)
assert len(saved_history[3].parts) == 1
assert isinstance(saved_history[3].parts[0], TextPart)
assert saved_history[3].parts[0].content == "done"
assert all(not any(isinstance(part, SystemPromptPart) for part in message.parts) for message in saved_history)
def test_runner_with_empty_history_layer_still_sends_system_prompt_and_saves_only_new_messages(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = RecordingTestModel(custom_output_text="done")
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return model # pyright: ignore[reportReturnType]
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
request = _request("current user", include_history=True)
request.session_snapshot = _history_session_snapshot([])
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-empty-history",
plugin_daemon_http_client=client,
).run()
asyncio.run(scenario())
request_parts = _flatten_message_parts(model.seen_requests[0])
assert isinstance(request_parts[0], SystemPromptPart)
assert request_parts[0].content == "system"
assert isinstance(request_parts[1], UserPromptPart)
assert request_parts[1].content == "current user"
terminal = sink.events["run-empty-history"][-1]
assert isinstance(terminal, RunSucceededEvent)
saved_history = _history_messages_from_snapshot(terminal.data.session_snapshot)
assert isinstance(saved_history[0], ModelRequest)
assert len(saved_history[0].parts) == 1
assert isinstance(saved_history[0].parts[0], UserPromptPart)
assert saved_history[0].parts[0].content == "current user"
assert isinstance(saved_history[1], ModelResponse)
assert len(saved_history[1].parts) == 1
assert isinstance(saved_history[1].parts[0], TextPart)
assert saved_history[1].parts[0].content == "done"
assert all(not any(isinstance(part, SystemPromptPart) for part in message.parts) for message in saved_history)
def test_runner_failure_with_history_layer_emits_failed_terminal_event_without_success_snapshot(
monkeypatch: pytest.MonkeyPatch,
) -> None:
model = RecordingTestModel(failure=RuntimeError("boom"))
stored_history = [
ModelRequest(parts=[UserPromptPart(content="old user")]),
ModelResponse(parts=[TextPart(content="old assistant")]),
]
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return model # pyright: ignore[reportReturnType]
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
request = _request("current user", include_history=True)
request.session_snapshot = _history_session_snapshot(stored_history)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
with pytest.raises(RuntimeError, match="boom"):
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-history-failure",
plugin_daemon_http_client=client,
).run()
asyncio.run(scenario())
assert [event.type for event in sink.events["run-history-failure"]] == ["run_started", "run_failed"]
assert sink.statuses["run-history-failure"] == "failed"
assert request.session_snapshot is not None
assert _history_messages_from_snapshot(request.session_snapshot) == stored_history
def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
@ -457,6 +232,7 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu
"required": ["title", "severity", "actions"],
"additionalProperties": False,
},
name="incident_summary",
description="Structured incident summary returned by the agent.",
strict=True,
)
@ -491,10 +267,10 @@ def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_resu
assert model.last_model_request_parameters is not None
assert len(model.last_model_request_parameters.output_tools) == 1
output_tool = model.last_model_request_parameters.output_tools[0]
assert output_tool.name == "final_output"
assert output_tool.name == "incident_summary"
assert output_tool.description == "Structured incident summary returned by the agent."
assert output_tool.parameters_json_schema["type"] == "object"
assert output_tool.parameters_json_schema["title"] == "final_output"
assert output_tool.parameters_json_schema["title"] == "incident_summary"
assert output_tool.parameters_json_schema["properties"] == {
"title": {"type": "string"},
"severity": {"type": "string", "enum": ["low", "medium", "high"]},
@ -545,6 +321,7 @@ def test_runner_retries_invalid_structured_output_and_eventually_succeeds(monkey
"required": ["title", "severity", "actions"],
"additionalProperties": False,
},
name="incident_summary",
description="Structured incident summary returned by the agent.",
)
)
@ -597,6 +374,7 @@ def test_runner_fails_when_invalid_structured_output_exhausts_retries(monkeypatc
"required": ["title", "severity", "actions"],
"additionalProperties": False,
},
name="incident_summary",
description="Structured incident summary returned by the agent.",
)
)
@ -633,6 +411,7 @@ def test_runner_rejects_invalid_output_layer_before_model_resolution(monkeypatch
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
request = _request(
output_config={
"name": "incident_summary",
"json_schema": _recursive_output_schema(),
}
)

View File

@ -1,151 +0,0 @@
import asyncio
import pytest
from pydantic_ai.messages import ModelRequest, ModelResponse, SystemPromptPart, TextPart, UserPromptPart
from agenton.compositor import Compositor, LayerNode
from agenton_collections.layers.pydantic_ai import (
PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
PydanticAIHistoryLayer,
)
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID
from dify_agent.protocol.schemas import RunComposition, RunLayerSpec
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.history import (
append_successful_run_history,
build_run_message_history,
get_history_layer,
validate_history_layer_composition,
)
def test_default_layer_providers_include_pydantic_ai_history_layer() -> None:
providers = create_default_layer_providers()
assert PYDANTIC_AI_HISTORY_LAYER_TYPE_ID in {provider.type_id for provider in providers}
def test_validate_history_layer_composition_accepts_absent_or_reserved_history_layer() -> None:
validate_history_layer_composition(RunComposition(layers=[]))
validate_history_layer_composition(
RunComposition(
layers=[
RunLayerSpec(
name=DIFY_AGENT_HISTORY_LAYER_ID,
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
)
]
)
)
def test_validate_history_layer_composition_rejects_multiple_history_layers() -> None:
composition = RunComposition(
layers=[
RunLayerSpec(name=DIFY_AGENT_HISTORY_LAYER_ID, type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
RunLayerSpec(name="secondary-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
]
)
with pytest.raises(ValueError, match="Only one 'pydantic_ai.history' layer is supported"):
validate_history_layer_composition(composition)
def test_validate_history_layer_composition_rejects_misnamed_history_layer() -> None:
composition = RunComposition(
layers=[
RunLayerSpec(name="chat-history", type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID),
]
)
with pytest.raises(ValueError, match="must use reserved layer name 'history'"):
validate_history_layer_composition(composition)
def test_validate_history_layer_composition_rejects_history_layer_dependencies() -> None:
composition = RunComposition(
layers=[
RunLayerSpec(
name=DIFY_AGENT_HISTORY_LAYER_ID,
type=PYDANTIC_AI_HISTORY_LAYER_TYPE_ID,
deps={"prompt": "prompt"},
)
]
)
with pytest.raises(ValueError, match="does not support dependencies"):
validate_history_layer_composition(composition)
def test_get_history_layer_returns_optional_active_history_layer() -> None:
compositor = Compositor([LayerNode(DIFY_AGENT_HISTORY_LAYER_ID, PydanticAIHistoryLayer)])
async def scenario() -> None:
async with compositor.enter() as run:
history_layer = get_history_layer(run)
assert isinstance(history_layer, PydanticAIHistoryLayer)
asyncio.run(scenario())
def test_build_run_message_history_renders_current_system_prompts_before_stored_history() -> None:
stored_history = [
ModelRequest(parts=[UserPromptPart(content="old user")]),
ModelResponse(parts=[TextPart(content="old assistant")]),
]
async def scenario() -> None:
message_history = await build_run_message_history(
system_prompts=[lambda: "current system", lambda: "current suffix"],
stored_history=stored_history,
)
assert message_history is not None
assert isinstance(message_history[0], ModelRequest)
assert [part.content for part in message_history[0].parts] == ["current system", "current suffix"]
assert message_history[1:] == stored_history
asyncio.run(scenario())
def test_build_run_message_history_returns_none_without_system_prompt_or_history() -> None:
async def scenario() -> None:
assert await build_run_message_history(system_prompts=[], stored_history=[]) is None
asyncio.run(scenario())
def test_build_run_message_history_renders_system_prompt_without_history_layer() -> None:
async def scenario() -> None:
message_history = await build_run_message_history(system_prompts=[lambda: "current system"], stored_history=[])
assert message_history is not None
assert len(message_history) == 1
assert isinstance(message_history[0], ModelRequest)
assert isinstance(message_history[0].parts[0], SystemPromptPart)
assert message_history[0].parts[0].content == "current system"
asyncio.run(scenario())
def test_build_run_message_history_rejects_context_dependent_prompt_functions() -> None:
def unsupported_prompt(_ctx: object) -> str:
return "current system"
async def scenario() -> None:
with pytest.raises(ValueError, match="zero-argument system prompts"):
await build_run_message_history(system_prompts=[unsupported_prompt], stored_history=[])
asyncio.run(scenario())
def test_append_successful_run_history_preserves_existing_message_order() -> None:
history_layer = PydanticAIHistoryLayer()
stored_history = [ModelRequest(parts=[UserPromptPart(content="old user")])]
new_messages = [ModelResponse(parts=[TextPart(content="new assistant")])]
history_layer.replace_messages(stored_history)
append_successful_run_history(history_layer, new_messages)
assert history_layer.message_history == [*stored_history, *new_messages]

View File

@ -30,13 +30,6 @@ class FakeRedis:
async def xadd(self, key: str, fields: Mapping[str, object]) -> str:
self.commands.append(("xadd", key, dict(fields)))
return self._append_stream_entry(key, fields)
def pipeline(self, transaction: bool = True, shard_hint: str | None = None) -> "FakeRedisPipeline":
self.commands.append(("pipeline", transaction, shard_hint))
return FakeRedisPipeline(self)
def _append_stream_entry(self, key: str, fields: Mapping[str, object]) -> str:
entries = self.streams.setdefault(key, [])
event_id = f"{len(entries) + 1}-0"
entries.append((event_id, dict(fields)))
@ -71,35 +64,6 @@ class FakeRedis:
return int(timestamp), int(sequence)
class FakeRedisPipeline:
redis: FakeRedis
results: list[object]
def __init__(self, redis: FakeRedis) -> None:
self.redis = redis
self.results = []
async def __aenter__(self) -> "FakeRedisPipeline":
return self
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
del exc_type, exc, traceback
def xadd(self, key: str, fields: Mapping[str, object]) -> "FakeRedisPipeline":
self.redis.commands.append(("xadd", key, dict(fields)))
self.results.append(self.redis._append_stream_entry(key, fields))
return self
def expire(self, key: str, seconds: int) -> "FakeRedisPipeline":
self.redis.commands.append(("expire", key, seconds))
self.results.append(True)
return self
async def execute(self) -> list[object]:
self.redis.commands.append(("execute",))
return list(self.results)
def test_create_run_writes_running_record_without_job_queue_and_with_retention() -> None:
redis = FakeRedis()
store = RedisRunStore(redis, prefix="test") # pyright: ignore[reportArgumentType]
@ -133,21 +97,15 @@ def test_append_event_serializes_typed_event_without_id_and_expires_run_keys() -
event_id = asyncio.run(store.append_event(RunStartedEvent(id="local", run_id="run-1")))
assert event_id == "1-0"
pipeline_commands = [command for command in redis.commands if command[0] == "pipeline"]
assert len(pipeline_commands) == 1
assert pipeline_commands[0][1] is True
xadd_commands = [command for command in redis.commands if command[0] == "xadd"]
assert len(xadd_commands) == 1
fields = xadd_commands[0][2]
assert redis.commands[0][0] == "xadd"
fields = redis.commands[0][2]
assert isinstance(fields, dict)
assert '"id"' not in str(fields["payload"])
assert '"type":"run_started"' in str(fields["payload"])
expire_commands = {command for command in redis.commands if command[0] == "expire"}
assert expire_commands == {
assert redis.commands[1:] == [
("expire", "test:runs:run-1:events", 60),
("expire", "test:runs:run-1:record", 60),
}
assert ("execute",) in redis.commands
]
def test_get_events_round_trips_run_succeeded_output_and_session_snapshot() -> None:

View File

@ -8,7 +8,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
CLIENT_SHARED_DTO_DEPENDENCIES = {
"httpx>=0.28.1",
"pydantic>=2.12.5,<3",
"pydantic>=2.13.3",
"pydantic-ai-slim>=1.85.1",
"typing-extensions>=4.12.2",
}

2
dify-agent/uv.lock generated
View File

@ -614,7 +614,7 @@ requires-dist = [
{ name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" },
{ name = "pydantic", specifier = ">=2.12.5,<3" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" },

View File

@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
@ -19,6 +20,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>

View File

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { AppContextProvider } from '@/context/app-context-provider'
@ -13,6 +14,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>

View File

@ -251,7 +251,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
return (
<>
<Dialog open={isShow} onOpenChange={open => !open && onHide()}>
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[520px] overflow-visible p-0">
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[520px] flex-col overflow-hidden p-0">
{/* header */}
<div className="pt-5 pr-5 pb-3 pl-6">
<div className="flex items-center gap-1">
@ -263,7 +263,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
</div>
{/* form body */}
<div className="space-y-5 px-6 py-3">
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-3">
{/* name & icon */}
<div className="flex gap-4">
<div className="grow">

View File

@ -334,7 +334,7 @@ describe('CloudPlanItem', () => {
expect(screen.queryByText('education.planNotSupportEducationDiscount')).not.toBeInTheDocument()
})
it('should show education unsupported warning below the button without changing button text or blocking checkout', async () => {
it('should show education unsupported warning and switch checkout to professional annual', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
@ -355,18 +355,18 @@ describe('CloudPlanItem', () => {
fireEvent.click(button)
expect(screen.getByText('education.educationPricingConfirm.title'))!.toBeInTheDocument()
expect(screen.getByText(/^education\.educationPricingConfirm\.description/))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.close' }))!.not.toBeInTheDocument()
expect(screen.getByText('education.educationPricingConfirm.description'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.close' }))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))!.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.continue' }))
await waitFor(() => {
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'month')
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.professional, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should close the unsupported plan confirm without checkout when canceled', async () => {
it('should continue selected plan checkout when keeping current plan', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
@ -384,6 +384,31 @@ describe('CloudPlanItem', () => {
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'education.educationPricingConfirm.cancel' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
expect(mockFetchSubscriptionUrls).toHaveBeenCalledWith(Plan.team, 'year')
expect(assignedHref).toBe('https://subscription.example')
})
})
it('should close the unsupported plan confirm without checkout when using the close button', async () => {
mockUseProviderContext.mockReturnValue({
enableEducationPlan: true,
isEducationAccount: true,
})
render(
<CloudPlanItem
plan={Plan.team}
currentPlan={Plan.sandbox}
planRange={PlanRange.yearly}
canPay
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => {
expect(screen.queryByText('education.educationPricingConfirm.title'))!.not.toBeInTheDocument()
})

View File

@ -1,15 +1,14 @@
'use client'
import type { FC } from 'react'
import type { BasicPlan } from '../../../type'
import { Button } from '@langgenius/dify-ui/button'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useMemo } from 'react'
@ -24,7 +23,7 @@ import { useEducationDiscount } from '../../../hooks/use-education-discount'
import { Plan } from '../../../type'
import { Professional, Sandbox, Team } from '../../assets'
import { PlanRange } from '../../plan-switcher/plan-range-switcher'
import Button from './button'
import PlanButton from './button'
import List from './list'
const ICON_MAP = {
@ -33,10 +32,6 @@ const ICON_MAP = {
[Plan.team]: <Team />,
}
type ConfirmType = {
type: 'info' | 'warning'
}
type CloudPlanItemProps = {
currentPlan: BasicPlan
plan: BasicPlan
@ -64,15 +59,12 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
const { enableEducationPlan, isEducationAccount } = useProviderContext()
const isEducationDiscountMode = enableEducationPlan && isEducationAccount
const isEducationDiscountSupportedPlan = plan === Plan.professional && isYear
const selectedPlanName = t(`${i18nPrefix}.name`, { ns: 'billing' })
const selectedBillingPeriod = t(`educationPricingConfirm.billingPeriod.${isYear ? 'yearly' : 'monthly'}`, { ns: 'education' })
const educationDiscountWarningText = canPay && isEducationDiscountMode && !isFreePlan && !isEducationDiscountSupportedPlan
? t('planNotSupportEducationDiscount', { ns: 'education' })
: undefined
const openAsyncWindow = useAsyncWindowOpen()
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const [showEducationPricingConfirm, setShowEducationPricingConfirm] = React.useState(false)
const educationPricingConfirmInfo: ConfirmType = { type: 'warning' }
const btnText = useMemo(() => {
if (canPay && isEducationDiscountMode && isEducationDiscountSupportedPlan && !isCurrent)
@ -139,16 +131,19 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
await handlePayCurrentPlan()
}
const handleContinueCurrentPlan = async () => {
setShowEducationPricingConfirm(false)
const handleSwitchToProfessionalAnnual = async () => {
await handleEducationDiscount()
}
const handleKeepCurrentPlan = async () => {
await handlePayCurrentPlan()
setShowEducationPricingConfirm(false)
}
return (
<div className="flex min-w-0 flex-1 flex-col pb-3">
<div className="flex flex-col px-5 py-4">
<div className="flex flex-col gap-y-6 px-1 pt-10">
{ICON_MAP[plan]}
<div className="flex min-h-[104px] flex-col gap-y-2">
<div className="flex min-h-26 flex-col gap-y-2">
<div className="flex items-center gap-x-2.5">
<div className="text-[30px] leading-[1.2] font-medium text-text-primary">{t(`${i18nPrefix}.name`, { ns: 'billing' })}</div>
{
@ -188,7 +183,7 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
</>
)}
</div>
<Button
<PlanButton
plan={plan}
isPlanDisabled={isPlanDisabled}
btnText={btnText}
@ -197,41 +192,49 @@ const CloudPlanItem: FC<CloudPlanItemProps> = ({
/>
</div>
<List plan={plan} />
<AlertDialog
<Dialog
open={showEducationPricingConfirm}
onOpenChange={setShowEducationPricingConfirm}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[520px] overflow-visible"
>
<DialogCloseButton
aria-label={t('operation.close', { ns: 'common' })}
className="top-6 right-6"
/>
<div className="flex flex-col gap-2 pr-10">
<DialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t('educationPricingConfirm.title', { ns: 'education' })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('educationPricingConfirm.description', {
ns: 'education',
planName: selectedPlanName,
billingPeriod: selectedBillingPeriod,
})}
</AlertDialogDescription>
</DialogTitle>
<DialogDescription className="w-full system-md-regular text-text-tertiary">
{t('educationPricingConfirm.description', { ns: 'education' })}
</DialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton
onClick={() => setShowEducationPricingConfirm(false)}
disabled={loading}
<div className="mt-10 flex items-start justify-end gap-3">
<Button
size="large"
onClick={handleKeepCurrentPlan}
disabled={loading || isEducationDiscountLoading}
loading={loading}
className="min-w-38"
>
{t('educationPricingConfirm.cancel', { ns: 'education' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
tone={educationPricingConfirmInfo.type !== 'info' ? 'destructive' : 'default'}
onClick={handleContinueCurrentPlan}
disabled={loading}
loading={loading}
</Button>
<Button
variant="primary"
size="large"
onClick={handleSwitchToProfessionalAnnual}
disabled={isEducationDiscountLoading}
loading={isEducationDiscountLoading}
className="min-w-61"
>
{t('educationPricingConfirm.continue', { ns: 'education' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -271,6 +271,12 @@ describe('Install Component', () => {
expect(screen.getByTestId('install-multi').parentElement).toHaveClass('overflow-y-auto')
})
it('should constrain the install step so the plugin list can scroll with many items', () => {
const { container } = render(<Install {...defaultProps} />)
expect(container.firstElementChild).toHaveClass('min-h-0', 'flex-1', 'overflow-hidden')
})
it('should show singular text when one plugin is selected', async () => {
render(<Install {...defaultProps} />)

View File

@ -170,8 +170,8 @@ const Install: FC<Props> = ({
const { canInstallPluginFromMarketplace } = useCanInstallPluginFromMarketplace()
return (
<>
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="flex min-h-0 flex-1 flex-col self-stretch overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col items-start justify-center gap-4 self-stretch overflow-hidden px-6 py-3">
<div className="system-md-regular text-text-secondary">
<p>{t(`${i18nPrefix}.${selectedPluginsNum > 1 ? 'readyToInstallPackages' : 'readyToInstallPackage'}`, { ns: 'plugin', num: selectedPluginsNum })}</p>
</div>
@ -218,7 +218,7 @@ const Install: FC<Props> = ({
</div>
)}
</>
</div>
)
}
export default React.memo(Install)

View File

@ -292,6 +292,12 @@ describe('InstallFromLocalPackage', () => {
expect(screen.getByTestId('is-bundle')).toHaveTextContent('true')
})
it('should constrain dialog height so bundle dependency lists can scroll', () => {
render(<InstallFromLocalPackage {...defaultProps} file={createMockBundleFile()} />)
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
})
it('should identify package file correctly', () => {
render(<InstallFromLocalPackage {...defaultProps} />)

View File

@ -93,7 +93,7 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
foldAnimInto()
}}
>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogCloseButton />
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">

View File

@ -212,6 +212,19 @@ describe('InstallFromMarketplace', () => {
expect(screen.getByTestId('bundle-plugins-count')).toHaveTextContent('2')
})
it('should constrain bundle dialog height so dependency lists can scroll', () => {
const dependencies = createMockDependencies()
render(
<InstallFromMarketplace
{...defaultProps}
isBundle={true}
dependencies={dependencies}
/>,
)
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-48px)]')
})
it('should pass isFromMarketPlace as true to bundle component', () => {
const dependencies = createMockDependencies()
render(

View File

@ -77,7 +77,7 @@ const InstallFromMarketplace: React.FC<InstallFromMarketplaceProps> = ({
foldAnimInto()
}}
>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogContent className={cn('w-[560px] max-w-none! overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex max-h-[calc(100dvh-48px)] min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
<DialogCloseButton />
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">

View File

@ -4,7 +4,6 @@ import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { Provider as JotaiProvider } from 'jotai/react'
import { ThemeProvider } from 'next-themes'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { IS_PROD } from '@/config'
import { TanstackQueryInitializer } from '@/context/query-client'
import { getDatasetMap } from '@/env'
@ -60,7 +59,6 @@ const LocaleLayout = async ({
{...datasetMap}
>
<div className="isolate h-full">
<AmplitudeProvider />
<JotaiProvider>
<ThemeProvider
attribute="data-theme"

View File

@ -48,7 +48,7 @@ export const tagUpdateContract = base
name: string
}
}>())
.output(type<unknown>())
.output(type<Tag>())
export const tagDeleteContract = base
.route({

View File

@ -30,7 +30,6 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
const updateTagMutation = useMutation(consoleQuery.tags.update.mutationOptions())
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(tag.name)
const editTag = (tagId: string, name: string) => {
if (name === tag.name) {
setIsEditing(false)
@ -38,7 +37,6 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
}
if (!name) {
toast.error('tag name is empty')
setName(tag.name)
setIsEditing(false)
return
}
@ -53,13 +51,11 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
}, {
onSuccess: () => {
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
setName(name)
setIsEditing(false)
onTagsChange?.()
},
onError: () => {
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
setName(tag.name)
setIsEditing(false)
},
})
@ -123,7 +119,22 @@ export const TagItemEditor = ({ tag, onTagsChange }: TagItemEditorProps) => {
</button>
</>
)}
{isEditing && (<input aria-label={`${t('operation.rename', { ns: 'common' })} ${tag.name}`} className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary" autoFocus value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && editTag(tag.id, name)} onBlur={() => editTag(tag.id, name)} />)}
{isEditing && (
<input
aria-label={`${t('operation.rename', { ns: 'common' })} ${tag.name}`}
className="shrink-0 appearance-none caret-primary-600 outline-hidden placeholder:text-text-quaternary"
autoFocus
defaultValue={tag.name}
onKeyDown={(e) => {
if (e.key !== 'Enter' || e.nativeEvent.isComposing)
return
e.preventDefault()
e.currentTarget.blur()
}}
onBlur={e => editTag(tag.id, e.currentTarget.value)}
/>
)}
</div>
<AlertDialog open={showRemoveModal} onOpenChange={open => !open && setShowRemoveModal(false)}>
<AlertDialogContent>

View File

@ -16,10 +16,10 @@
"currentSigned": "تم تسجيل الدخول حاليًا باسم",
"educationPricingConfirm.billingPeriod.monthly": "شهري",
"educationPricingConfirm.billingPeriod.yearly": "سنوي",
"educationPricingConfirm.cancel": "إلغاء",
"educationPricingConfirm.continue": "المتابعة بدون خصم",
"educationPricingConfirm.description": "خطتك {{planName}} {{billingPeriod}} لا تدعم الخصم التعليمي. فقط خطة Professional السنوية مؤهلة.",
"educationPricingConfirm.title": "الخصم التعليمي غير متاح",
"educationPricingConfirm.cancel": "الاحتفاظ بالخطة الحالية",
"educationPricingConfirm.continue": "التبديل إلى Professional السنوية",
"educationPricingConfirm.description": "ينطبق الخصم التعليمي على خطة Professional السنوية فقط. الاحتفاظ بخطتك الحالية لن يتضمن الخصم.",
"educationPricingConfirm.title": "الخطة التي اخترتها لا تدعم الخصم التعليمي",
"emailLabel": "بريدك الإلكتروني الحالي",
"form.schoolName.placeholder": "أدخل الاسم الرسمي الكامل لمدرستك",
"form.schoolName.title": "اسم مدرستك",

View File

@ -16,10 +16,10 @@
"currentSigned": "DERZEIT ANGEMELDET ALS",
"educationPricingConfirm.billingPeriod.monthly": "monatlich",
"educationPricingConfirm.billingPeriod.yearly": "jährlich",
"educationPricingConfirm.cancel": "Abbrechen",
"educationPricingConfirm.continue": "Ohne Rabatt fortfahren",
"educationPricingConfirm.description": "Ihr {{planName}} {{billingPeriod}} Plan unterstützt den Bildungsrabatt nicht. Nur der Professional-Jahresplan ist berechtigt.",
"educationPricingConfirm.title": "Bildungsrabatt nicht verfügbar",
"educationPricingConfirm.cancel": "Aktuellen Plan behalten",
"educationPricingConfirm.continue": "Zu Professional jährlich wechseln",
"educationPricingConfirm.description": "Der Bildungsrabatt gilt nur für den jährlichen Professional-Plan. Wenn Sie Ihren aktuellen Plan behalten, ist der Rabatt nicht enthalten.",
"educationPricingConfirm.title": "Ihr ausgewählter Plan unterstützt den Bildungsrabatt nicht",
"emailLabel": "Ihre aktuelle E-Mail",
"form.schoolName.placeholder": "Geben Sie den offiziellen, unabgekürzten Namen Ihrer Schule ein.",
"form.schoolName.title": "Ihr Schulname",

View File

@ -16,10 +16,10 @@
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "monthly",
"educationPricingConfirm.billingPeriod.yearly": "annual",
"educationPricingConfirm.cancel": "Cancel",
"educationPricingConfirm.continue": "Continue without discount",
"educationPricingConfirm.description": "Your {{planName}} {{billingPeriod}} plan doesn't support the education discount. Only the Professional annual plan is eligible.",
"educationPricingConfirm.title": "Education discount not available",
"educationPricingConfirm.cancel": "Keep current plan",
"educationPricingConfirm.continue": "Switch to Professional Annual",
"educationPricingConfirm.description": "The education discount applies to the Professional annual plan only. Keeping your current plan won't include the discount.",
"educationPricingConfirm.title": "Your selected plan doesn't support the education discount",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",

View File

@ -16,10 +16,10 @@
"currentSigned": "ACTUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensual",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sin descuento",
"educationPricingConfirm.description": "Tu plan {{planName}} {{billingPeriod}} no admite el descuento educativo. Solo el plan Professional anual es elegible.",
"educationPricingConfirm.title": "Descuento educativo no disponible",
"educationPricingConfirm.cancel": "Mantener el plan actual",
"educationPricingConfirm.continue": "Cambiar a Professional anual",
"educationPricingConfirm.description": "El descuento educativo solo se aplica al plan Professional anual. Si mantienes tu plan actual, no se incluirá el descuento.",
"educationPricingConfirm.title": "El plan seleccionado no admite el descuento educativo",
"emailLabel": "Tu correo electrónico actual",
"form.schoolName.placeholder": "Ingrese el nombre oficial y completo de su escuela",
"form.schoolName.title": "El nombre de tu escuela",

View File

@ -16,10 +16,10 @@
"currentSigned": "اکنون به عنوان",
"educationPricingConfirm.billingPeriod.monthly": "ماهانه",
"educationPricingConfirm.billingPeriod.yearly": "سالانه",
"educationPricingConfirm.cancel": "لغو",
"educationPricingConfirm.continue": "ادامه بدون تخفیف",
"educationPricingConfirm.description": "طرح {{planName}} {{billingPeriod}} شما از تخفیف آموزشی پشتیبانی نمی‌کند. فقط طرح سالانه Professional واجد شرایط است.",
"educationPricingConfirm.title": "تخفیف آموزشی در دسترس نیست",
"educationPricingConfirm.cancel": "حفظ طرح فعلی",
"educationPricingConfirm.continue": "تغییر به Professional سالانه",
"educationPricingConfirm.description": "تخفیف آموزشی فقط برای طرح سالانه Professional اعمال می‌شود. با حفظ طرح فعلی، این تخفیف شامل نمی‌شود.",
"educationPricingConfirm.title": "طرح انتخاب‌شده شما از تخفیف آموزشی پشتیبانی نمی‌کند",
"emailLabel": "ایمیل فعلی شما",
"form.schoolName.placeholder": "نام رسمی و کامل مدرسه خود را وارد کنید",
"form.schoolName.title": "نام مدرسه شما",

View File

@ -16,10 +16,10 @@
"currentSigned": "ACTUELLEMENT CONNECTÉ EN TANT QUE",
"educationPricingConfirm.billingPeriod.monthly": "mensuel",
"educationPricingConfirm.billingPeriod.yearly": "annuel",
"educationPricingConfirm.cancel": "Annuler",
"educationPricingConfirm.continue": "Continuer sans remise",
"educationPricingConfirm.description": "Votre plan {{planName}} {{billingPeriod}} ne prend pas en charge la remise éducative. Seul le plan Professional annuel est éligible.",
"educationPricingConfirm.title": "Remise éducative non disponible",
"educationPricingConfirm.cancel": "Conserver le plan actuel",
"educationPricingConfirm.continue": "Passer à Professional annuel",
"educationPricingConfirm.description": "La remise éducation s'applique uniquement au plan Professional annuel. En conservant votre plan actuel, la remise ne sera pas incluse.",
"educationPricingConfirm.title": "Le plan sélectionné ne prend pas en charge la remise éducation",
"emailLabel": "Votre email actuel",
"form.schoolName.placeholder": "Entrez le nom officiel et complet de votre école",
"form.schoolName.title": "Le nom de votre école",

View File

@ -16,10 +16,10 @@
"currentSigned": "वर्तमान में साइन इन किया गया है के रूप में",
"educationPricingConfirm.billingPeriod.monthly": "मासिक",
"educationPricingConfirm.billingPeriod.yearly": "वार्षिक",
"educationPricingConfirm.cancel": "रद्द करें",
"educationPricingConfirm.continue": "छूट के बिना जारी रखें",
"educationPricingConfirm.description": "आपका {{planName}} {{billingPeriod}} प्लान शिक्षा छूट का समर्थन नहीं करता। केवल Professional वार्षिक प्लान पात्र है।",
"educationPricingConfirm.title": "शिक्षा छूट उपलब्ध नहीं",
"educationPricingConfirm.cancel": "वर्तमान प्लान रखें",
"educationPricingConfirm.continue": "Professional वार्षिक पर स्विच करें",
"educationPricingConfirm.description": "शिक्षा छूट केवल Professional वार्षिक प्लान पर लागू होती है। अपना वर्तमान प्लान रखने पर छूट शामिल नहीं होगी।",
"educationPricingConfirm.title": "आपका चुना हुआ प्लान शिक्षा छूट का समर्थन नहीं करता",
"emailLabel": "आपका वर्तमान ईमेल",
"form.schoolName.placeholder": "अपनी स्कूल का आधिकारिक, बिना संक्षिप्त नाम दर्ज करें",
"form.schoolName.title": "आपके स्कूल का नाम",

View File

@ -16,10 +16,10 @@
"currentSigned": "SAAT INI MASUK SEBAGAI",
"educationPricingConfirm.billingPeriod.monthly": "bulanan",
"educationPricingConfirm.billingPeriod.yearly": "tahunan",
"educationPricingConfirm.cancel": "Batal",
"educationPricingConfirm.continue": "Lanjutkan tanpa diskon",
"educationPricingConfirm.description": "Paket {{planName}} {{billingPeriod}} Anda tidak mendukung diskon pendidikan. Hanya paket Professional tahunan yang memenuhi syarat.",
"educationPricingConfirm.title": "Diskon pendidikan tidak tersedia",
"educationPricingConfirm.cancel": "Tetap gunakan paket saat ini",
"educationPricingConfirm.continue": "Beralih ke Professional Tahunan",
"educationPricingConfirm.description": "Diskon pendidikan hanya berlaku untuk paket Professional tahunan. Jika tetap menggunakan paket saat ini, diskon tidak akan disertakan.",
"educationPricingConfirm.title": "Paket yang Anda pilih tidak mendukung diskon pendidikan",
"emailLabel": "Email Anda saat ini",
"form.schoolName.placeholder": "Masukkan nama resmi sekolah Anda yang tidak disingkat",
"form.schoolName.title": "Nama Sekolah Anda",

View File

@ -16,10 +16,10 @@
"currentSigned": "ATTUALMENTE ACCEDUTO COME",
"educationPricingConfirm.billingPeriod.monthly": "mensile",
"educationPricingConfirm.billingPeriod.yearly": "annuale",
"educationPricingConfirm.cancel": "Annulla",
"educationPricingConfirm.continue": "Continua senza sconto",
"educationPricingConfirm.description": "Il tuo piano {{planName}} {{billingPeriod}} non supporta lo sconto educativo. Solo il piano Professional annuale è idoneo.",
"educationPricingConfirm.title": "Sconto educativo non disponibile",
"educationPricingConfirm.cancel": "Mantieni il piano attuale",
"educationPricingConfirm.continue": "Passa a Professional annuale",
"educationPricingConfirm.description": "Lo sconto Education si applica solo al piano Professional annuale. Mantenendo il piano attuale, lo sconto non verrà incluso.",
"educationPricingConfirm.title": "Il piano selezionato non supporta lo sconto Education",
"emailLabel": "La tua email attuale",
"form.schoolName.placeholder": "Inserisci il nome ufficiale e completo della tua scuola",
"form.schoolName.title": "Il Nome della tua Scuola",

View File

@ -16,10 +16,10 @@
"currentSigned": "現在ログイン中のアカウントは",
"educationPricingConfirm.billingPeriod.monthly": "月次",
"educationPricingConfirm.billingPeriod.yearly": "年次",
"educationPricingConfirm.cancel": "キャンセル",
"educationPricingConfirm.continue": "割引なしで続行",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} プランは教育割引に対応していません。Professional 年プランのみが対象です。",
"educationPricingConfirm.title": "教育割引は利用できません",
"educationPricingConfirm.cancel": "現在のプランを維持",
"educationPricingConfirm.continue": "Professional 年間プランに切り替える",
"educationPricingConfirm.description": "教育割引は Professional 年プランのみ適用されます。現在のプランを維持すると、割引は適用されません。",
"educationPricingConfirm.title": "選択したプランは教育割引に対応していません",
"emailLabel": "現在のメールアドレス",
"form.schoolName.placeholder": "学校の正式名称(省略不可)を入力してください。",
"form.schoolName.title": "学校名",

View File

@ -16,10 +16,10 @@
"currentSigned": "현재 로그인 중입니다",
"educationPricingConfirm.billingPeriod.monthly": "월간",
"educationPricingConfirm.billingPeriod.yearly": "연간",
"educationPricingConfirm.cancel": "취소",
"educationPricingConfirm.continue": "할인 없이 계속",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} 플랜은 교육 할인을 지원하지 않습니다. Professional 연간 플랜만 자격이 있습니다.",
"educationPricingConfirm.title": "교육 할인 불가",
"educationPricingConfirm.cancel": "현재 플랜 유지",
"educationPricingConfirm.continue": "Professional 연간으로 전환",
"educationPricingConfirm.description": "교육 할인은 Professional 연간 플랜적용됩니다. 현재 플랜을 유지하면 할인이 포함되지 않습니다.",
"educationPricingConfirm.title": "선택한 플랜은 교육 할인을 지원하지 않습니다",
"emailLabel": "현재 이메일",
"form.schoolName.placeholder": "귀하의 학교의 공식 약어가 아닌 전체 이름을 입력하세요.",
"form.schoolName.title": "당신의 학교 이름",

View File

@ -16,10 +16,10 @@
"currentSigned": "CURRENTLY SIGNED IN AS",
"educationPricingConfirm.billingPeriod.monthly": "maandelijks",
"educationPricingConfirm.billingPeriod.yearly": "jaarlijks",
"educationPricingConfirm.cancel": "Annuleren",
"educationPricingConfirm.continue": "Doorgaan zonder korting",
"educationPricingConfirm.description": "Uw {{planName}} {{billingPeriod}} abonnement ondersteunt de onderwijskorting niet. Alleen het jaarlijkse Professional abonnement komt in aanmerking.",
"educationPricingConfirm.title": "Onderwijskorting niet beschikbaar",
"educationPricingConfirm.cancel": "Huidig abonnement behouden",
"educationPricingConfirm.continue": "Overschakelen naar Professional jaarlijks",
"educationPricingConfirm.description": "De onderwijskorting is alleen van toepassing op het jaarlijkse Professional-abonnement. Als u uw huidige abonnement behoudt, is de korting niet inbegrepen.",
"educationPricingConfirm.title": "Uw geselecteerde abonnement ondersteunt de onderwijskorting niet",
"emailLabel": "Your current email",
"form.schoolName.placeholder": "Enter the official, unabbreviated name of your school",
"form.schoolName.title": "Your School Name",

View File

@ -16,10 +16,10 @@
"currentSigned": "AKTUALNIE ZALOGOWANY JAKO",
"educationPricingConfirm.billingPeriod.monthly": "miesięcznie",
"educationPricingConfirm.billingPeriod.yearly": "rocznie",
"educationPricingConfirm.cancel": "Anuluj",
"educationPricingConfirm.continue": "Kontynuuj bez rabatu",
"educationPricingConfirm.description": "Twój plan {{planName}} {{billingPeriod}} nie obsługuje rabatu edukacyjnego. Tylko roczny plan Professional jest uprawniony.",
"educationPricingConfirm.title": "Rabat edukacyjny niedostępny",
"educationPricingConfirm.cancel": "Zachowaj obecny plan",
"educationPricingConfirm.continue": "Przełącz na Professional roczny",
"educationPricingConfirm.description": "Zniżka edukacyjna dotyczy tylko rocznego planu Professional. Pozostanie przy obecnym planie nie obejmie zniżki.",
"educationPricingConfirm.title": "Wybrany plan nie obsługuje zniżki edukacyjnej",
"emailLabel": "Twój aktualny email",
"form.schoolName.placeholder": "Wpisz oficjalną, pełną nazwę swojej szkoły",
"form.schoolName.title": "Nazwa Twojej Szkoły",

View File

@ -16,10 +16,10 @@
"currentSigned": "ATUALMENTE CONECTADO COMO",
"educationPricingConfirm.billingPeriod.monthly": "mensal",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Cancelar",
"educationPricingConfirm.continue": "Continuar sem desconto",
"educationPricingConfirm.description": "Seu plano {{planName}} {{billingPeriod}} não suporta o desconto educacional. Apenas o plano Professional anual é elegível.",
"educationPricingConfirm.title": "Desconto educacional não disponível",
"educationPricingConfirm.cancel": "Manter plano atual",
"educationPricingConfirm.continue": "Mudar para Professional anual",
"educationPricingConfirm.description": "O desconto educacional se aplica apenas ao plano Professional anual. Manter seu plano atual não incluirá o desconto.",
"educationPricingConfirm.title": "O plano selecionado não aceita o desconto educacional",
"emailLabel": "Seu e-mail atual",
"form.schoolName.placeholder": "Digite o nome oficial e não abreviado da sua escola",
"form.schoolName.title": "O nome da sua escola",

View File

@ -16,10 +16,10 @@
"currentSigned": "CONEXIUNE ÎN PREZENT CA",
"educationPricingConfirm.billingPeriod.monthly": "lunar",
"educationPricingConfirm.billingPeriod.yearly": "anual",
"educationPricingConfirm.cancel": "Anulează",
"educationPricingConfirm.continue": "Continuă fără reducere",
"educationPricingConfirm.description": "Planul tău {{planName}} {{billingPeriod}} nu suportă reducerea educațională. Doar planul Professional anual este eligibil.",
"educationPricingConfirm.title": "Reducerea educațională nu este disponibilă",
"educationPricingConfirm.cancel": "Păstrează planul curent",
"educationPricingConfirm.continue": "Treci la Professional anual",
"educationPricingConfirm.description": "Reducerea educațională se aplică doar planului Professional anual. Dacă păstrezi planul curent, reducerea nu va fi inclusă.",
"educationPricingConfirm.title": "Planul selectat nu acceptă reducerea educațională",
"emailLabel": "Emailul tău curent",
"form.schoolName.placeholder": "Introduceți numele oficial, neabbreviat al școlii dumneavoastră",
"form.schoolName.title": "Numele Școlii Tale",

View File

@ -16,10 +16,10 @@
"currentSigned": "В ДАННЫЙ МОМЕНТ ВХОД В ПРОФИЛЬ КАК",
"educationPricingConfirm.billingPeriod.monthly": "ежемесячно",
"educationPricingConfirm.billingPeriod.yearly": "ежегодно",
"educationPricingConfirm.cancel": "Отмена",
"educationPricingConfirm.continue": родолжить без скидки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не поддерживает образовательную скидку. Только годовой план Professional имеет право на скидку.",
"educationPricingConfirm.title": "Образовательная скидка недоступна",
"educationPricingConfirm.cancel": "Оставить текущий план",
"educationPricingConfirm.continue": ерейти на Professional годовой",
"educationPricingConfirm.description": "Образовательная скидка применяется только к годовому плану Professional. Если оставить текущий план, скидка не будет включена.",
"educationPricingConfirm.title": "Выбранный план не поддерживает образовательную скидку",
"emailLabel": "Ваш текущий адрес электронной почты",
"form.schoolName.placeholder": "Введите официальное, полное название вашей школы",
"form.schoolName.title": "Название вашей школы",

View File

@ -16,10 +16,10 @@
"currentSigned": "Trenutno prijavljen kot",
"educationPricingConfirm.billingPeriod.monthly": "mesečno",
"educationPricingConfirm.billingPeriod.yearly": "letno",
"educationPricingConfirm.cancel": "Prekliči",
"educationPricingConfirm.continue": "Nadaljuj brez popusta",
"educationPricingConfirm.description": "Vaš načrt {{planName}} {{billingPeriod}} ne podpira izobraževalnega popusta. Do popusta je upravičen samo letni načrt Professional.",
"educationPricingConfirm.title": "Izobraževalni popust ni na voljo",
"educationPricingConfirm.cancel": "Obdrži trenutni paket",
"educationPricingConfirm.continue": "Preklopi na letni Professional",
"educationPricingConfirm.description": "Izobraževalni popust velja samo za letni paket Professional. Če obdržite trenutni paket, popust ne bo vključen.",
"educationPricingConfirm.title": "Izbrani paket ne podpira izobraževalnega popusta",
"emailLabel": "Vaš trenutni elektronski naslov",
"form.schoolName.placeholder": "Vpišite uradno, neokrnjeno ime vaše šole",
"form.schoolName.title": "Ime vaše šole",

View File

@ -16,10 +16,10 @@
"currentSigned": "ลงชื่อเข้าใช้ในฐานะ",
"educationPricingConfirm.billingPeriod.monthly": "รายเดือน",
"educationPricingConfirm.billingPeriod.yearly": "รายปี",
"educationPricingConfirm.cancel": "ยกเลิก",
"educationPricingConfirm.continue": "ดำเนินการต่อโดยไม่มีส่วนลด",
"educationPricingConfirm.description": "แผน {{planName}} {{billingPeriod}} ของคุณไม่รองรับส่วนลดการศึกษา เฉพาะแผน Professional รายปีเท่านั้นที่มีสิทธิ์",
"educationPricingConfirm.title": "ส่วนลดการศึกษาไม่พร้อมใช้งาน",
"educationPricingConfirm.cancel": "ใช้แผนปัจจุบันต่อ",
"educationPricingConfirm.continue": "เปลี่ยนเป็น Professional รายปี",
"educationPricingConfirm.description": "ส่วนลดการศึกษาใช้ได้เฉพาะกับแผน Professional รายปีเท่านั้น หากใช้แผนปัจจุบันต่อ จะไม่มีส่วนลดนี้รวมอยู่ด้วย",
"educationPricingConfirm.title": "แผนที่คุณเลือกไม่รองรับส่วนลดการศึกษา",
"emailLabel": "อีเมลปัจจุบันของคุณ",
"form.schoolName.placeholder": "กรุณาใส่ชื่อของโรงเรียนอย่างเป็นทางการที่ไม่มีการย่อ",
"form.schoolName.title": "ชื่อโรงเรียนของคุณ",

View File

@ -16,10 +16,10 @@
"currentSigned": "ŞU ANDA GİRİŞ YAPILDIĞI KİŞİ",
"educationPricingConfirm.billingPeriod.monthly": "aylık",
"educationPricingConfirm.billingPeriod.yearly": "yıllık",
"educationPricingConfirm.cancel": "İptal",
"educationPricingConfirm.continue": "İndirim olmadan devam et",
"educationPricingConfirm.description": "{{planName}} {{billingPeriod}} planınız eğitim indirimini desteklemiyor. Yalnızca yıllık Professional planı uygundur.",
"educationPricingConfirm.title": "Eğitim indirimi mevcut değil",
"educationPricingConfirm.cancel": "Mevcut planı koru",
"educationPricingConfirm.continue": "Professional yıllık plana geç",
"educationPricingConfirm.description": "Eğitim indirimi yalnızca yıllık Professional planı için geçerlidir. Mevcut planınızı korursanız indirim dahil edilmez.",
"educationPricingConfirm.title": "Seçtiğiniz plan eğitim indirimini desteklemiyor",
"emailLabel": "Şu anki e-posta adresin",
"form.schoolName.placeholder": "Okulunuzun resmi, kısaltılmamış adını girin",
"form.schoolName.title": "Okulunuzun Adı",

View File

@ -16,10 +16,10 @@
"currentSigned": "В даний момент ви підписані як",
"educationPricingConfirm.billingPeriod.monthly": "щомісячно",
"educationPricingConfirm.billingPeriod.yearly": "щорічно",
"educationPricingConfirm.cancel": "Скасувати",
"educationPricingConfirm.continue": родовжити без знижки",
"educationPricingConfirm.description": "Ваш план {{planName}} {{billingPeriod}} не підтримує освітню знижку. Лише річний план Professional має право на знижку.",
"educationPricingConfirm.title": "Освітня знижка недоступна",
"educationPricingConfirm.cancel": "Залишити поточний план",
"educationPricingConfirm.continue": ерейти на Professional річний",
"educationPricingConfirm.description": "Освітня знижка застосовується лише до річного плану Professional. Якщо залишити поточний план, знижку не буде включено.",
"educationPricingConfirm.title": "Вибраний план не підтримує освітню знижку",
"emailLabel": "Ваш поточний електронний лист",
"form.schoolName.placeholder": "Введіть офіційну, повну назву вашої школи",
"form.schoolName.title": "Ваша назва школи",

View File

@ -16,10 +16,10 @@
"currentSigned": "HIỆN ĐANG ĐĂNG NHẬP VÀO",
"educationPricingConfirm.billingPeriod.monthly": "hàng tháng",
"educationPricingConfirm.billingPeriod.yearly": "hàng năm",
"educationPricingConfirm.cancel": "Hủy",
"educationPricingConfirm.continue": "Tiếp tục không có giảm giá",
"educationPricingConfirm.description": "Gói {{planName}} {{billingPeriod}} của bạn không hỗ trợ giảm giá giáo dục. Chỉ gói Professional hàng năm mới được áp dụng.",
"educationPricingConfirm.title": "Giảm giá giáo dục không khả dụng",
"educationPricingConfirm.cancel": "Giữ gói hiện tại",
"educationPricingConfirm.continue": "Chuyển sang Professional hằng năm",
"educationPricingConfirm.description": "Giảm giá giáo dục chỉ áp dụng cho gói Professional hng năm. Nếu giữ gói hiện tại, giảm giá sẽ không được áp dụng.",
"educationPricingConfirm.title": "Gói bạn chọn không hỗ trợ giảm giá giáo dục",
"emailLabel": "Email hiện tại của bạn",
"form.schoolName.placeholder": "Nhập tên chính thức, không viết tắt của trường bạn",
"form.schoolName.title": "Tên Trường Của Bạn",

View File

@ -16,10 +16,10 @@
"currentSigned": "您当前登录的账户是",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用优惠继续",
"educationPricingConfirm.description": "你的 {{planName}} 计划{{billingPeriod}}不支持教育优惠。只有 Professional 年付计划符合条件。",
"educationPricingConfirm.title": "教育优惠不适用于该计划",
"educationPricingConfirm.cancel": "保留当前计划",
"educationPricingConfirm.continue": "切换到 Professional 年付",
"educationPricingConfirm.description": "教育优惠仅适用于 Professional 年付计划。保留当前计划将不包含该优惠。",
"educationPricingConfirm.title": "你选择的计划不支持教育优惠",
"emailLabel": "您当前的邮箱",
"form.schoolName.placeholder": "请输入您的学校的官方全称(不得缩写)",
"form.schoolName.title": "您的学校名称",

View File

@ -16,10 +16,10 @@
"currentSigned": "當前以以下身份登入",
"educationPricingConfirm.billingPeriod.monthly": "月付",
"educationPricingConfirm.billingPeriod.yearly": "年付",
"educationPricingConfirm.cancel": "取消",
"educationPricingConfirm.continue": "不使用優惠繼續",
"educationPricingConfirm.description": "你的 {{planName}} 方案{{billingPeriod}}不支援教育優惠。只有 Professional 年付方案符合資格。",
"educationPricingConfirm.title": "教育優惠不適用於此方案",
"educationPricingConfirm.cancel": "保留目前方案",
"educationPricingConfirm.continue": "切換到 Professional 年付",
"educationPricingConfirm.description": "教育優惠僅適用於 Professional 年付方案。保留目前方案將不包含此優惠。",
"educationPricingConfirm.title": "你選擇的方案不支援教育優惠",
"emailLabel": "您當前的電子郵件",
"form.schoolName.placeholder": "請輸入您學校的正式全名",
"form.schoolName.title": "你的學校名稱",

View File

@ -187,15 +187,20 @@ describe('consoleQuery tag mutation defaults', () => {
queryClient.setQueryData(appListKey, [targetTag, otherTag])
queryClient.setQueryData(knowledgeListKey, [knowledgeTag])
const updatedTag = createTag({
...targetTag,
name: 'After',
binding_count: 5,
})
const mutationOptions = consoleQuery.tags.update.mutationOptions()
await mutationOptions.onSuccess?.(
undefined,
updatedTag,
{
params: {
tagId: targetTag.id,
},
body: {
name: 'After',
name: 'Ignored Client Name',
},
},
undefined,
@ -203,10 +208,7 @@ describe('consoleQuery tag mutation defaults', () => {
)
expect(queryClient.getQueryData(appListKey)).toEqual([
{
...targetTag,
name: 'After',
},
updatedTag,
otherTag,
])
expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag])

View File

@ -108,16 +108,13 @@ export const consoleQuery = createTanstackQueryUtils(consoleClient, {
},
update: {
mutationOptions: {
onSuccess: (_data, variables, _onMutateResult, context) => {
onSuccess: (updatedTag, variables, _onMutateResult, context) => {
context.client.setQueriesData(
{
queryKey: consoleQuery.tags.list.key({ type: 'query' }),
},
(oldTags: Tag[] | undefined) => oldTags?.map(tag => tag.id === variables.params.tagId
? {
...tag,
name: variables.body.name,
}
? updatedTag
: tag),
)
},