Compare commits

..
Author SHA1 Message Date
yyh 0b9e03c2c1 fix(agent-v2): preserve interrupted build input 2026-06-28 03:49:25 +08:00
autofix-ci[bot]andGitHub b34eb8f24b [autofix.ci] apply automated fixes 2026-06-27 19:42:43 +00:00
yyh 625cfad888 fix(agent-v2): fence stale build draft refresh 2026-06-28 03:36:34 +08:00
yyh 3859367e3a fix(agent-v2): cancel inline build refresh timer 2026-06-28 03:21:09 +08:00
yyh 92c0974e19 fix(agent-v2): fail explicit draft save commands 2026-06-28 03:14:51 +08:00
yyh f7d859afac fix(agent-v2): guard build draft refresh lifecycle 2026-06-28 03:12:34 +08:00
yyh b522f7df40 fix(agent-v2): align chat feature draft source 2026-06-28 03:07:16 +08:00
autofix-ci[bot]andGitHub 1787145f2a [autofix.ci] apply automated fixes 2026-06-27 19:00:10 +00:00
yyh f581dfc016 docs(agent-v2): align configure module boundary 2026-06-28 02:54:09 +08:00
yyh 26291a0f42 test(agent-v2): tighten state graph review coverage 2026-06-28 02:49:03 +08:00
yyh 80b9f5e083 fix(agent-v2): align inline build draft source 2026-06-28 01:55:05 +08:00
yyh bbebeec3dc fix(chat): settle stream read failures 2026-06-28 01:43:30 +08:00
yyh 6a69ec2a8a fix(agent-v2): honor debug refresh contract 2026-06-28 01:33:46 +08:00
autofix-ci[bot]andGitHub f874a6a019 [autofix.ci] apply automated fixes 2026-06-27 17:31:32 +00:00
yyh bcdd52a27b fix(agent-v2): reconcile publish state owners 2026-06-28 01:24:56 +08:00
autofix-ci[bot]andGitHub 32e9de8ae3 [autofix.ci] apply automated fixes 2026-06-27 17:17:33 +00:00
yyh 35c9e3e3d3 fix(agent-v2): close build draft state gaps 2026-06-28 01:12:04 +08:00
autofix-ci[bot]andGitHub d37783c895 [autofix.ci] apply automated fixes 2026-06-27 16:55:18 +00:00
yyh cceeabcb46 fix(agent-v2): align build draft lifecycle owners 2026-06-28 00:50:01 +08:00
yyh 8536c232ba fix(agent-v2): stabilize build chat run state 2026-06-28 00:35:16 +08:00
yyh 8383571c53 fix(agent-v2): preserve publish state for reverted drafts 2026-06-28 00:35:00 +08:00
yyh 953b42ac67 fix(agent-v2): clear publish action after reverted draft 2026-06-28 00:10:39 +08:00
yyh 4e069c1f23 fix(agent-v2): save effective model before build run 2026-06-28 00:07:08 +08:00
yyh 09b5d63c39 refactor: merge inline agent common translations 2026-06-28 00:05:05 +08:00
yyh 8f2cd4e5b0 fix(agent-v2): disable restart when chat reset is unavailable 2026-06-28 00:01:51 +08:00
yyh b57e5da995 fix(agent-v2): stabilize chat reset command 2026-06-27 23:52:51 +08:00
yyh daf13834a6 fix(agent-v2): stabilize build chat state 2026-06-27 23:47:19 +08:00
yyh 309777f8fc fix(agent-v2): skip unchanged draft save before build 2026-06-27 23:19:49 +08:00
yyh 184670f49d fix(agent-v2): clarify build draft action states 2026-06-27 23:10:58 +08:00
yyh 63aa8af5c1 refactor(agent-v2): scope configure conversation state 2026-06-27 23:09:31 +08:00
yyh ee30e59871 fix(agent-v2): keep build chat visible after conversation starts 2026-06-27 23:01:06 +08:00
yyh 0199589c3b refactor(agent-v2): clarify configure state graph 2026-06-27 22:57:36 +08:00
313 changed files with 9195 additions and 11880 deletions
+69 -217
View File
@@ -1,52 +1,12 @@
from __future__ import annotations
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
import click
from sqlalchemy import select
from configs import dify_config
from core.db.session_factory import session_factory
from models import TenantAccountJoin, TenantAccountRole
from services.enterprise.rbac_service import ListOption, RBACService
_LEGACY_ROLE_TO_BUILTIN_TAG = {
TenantAccountRole.OWNER.value: "owner",
TenantAccountRole.ADMIN.value: "admin",
TenantAccountRole.EDITOR.value: "editor",
TenantAccountRole.NORMAL.value: "normal",
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
}
def _resolve_builtin_role_ids(tenant_id: str, operator_account_id: str) -> dict[str, str]:
"""Resolve every legacy workspace role to the current tenant's builtin RBAC role id.
The migration replays the old `TenantAccountJoin.role` values onto the
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
identified by runtime ids, so the command must look them up per tenant.
"""
roles = RBACService.Roles.list(
tenant_id=tenant_id,
account_id=operator_account_id,
options=ListOption(page_number=1, results_per_page=100),
).data
role_id_by_tag = {
role.role_tag: role.id
for role in roles
if role.is_builtin and role.category == "global_system_default" and role.role_tag
}
resolved: dict[str, str] = {}
for legacy_role, expected_builtin_tag in _LEGACY_ROLE_TO_BUILTIN_TAG.items():
role_id = role_id_by_tag.get(expected_builtin_tag)
if expected_builtin_tag == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED:
continue
if not role_id:
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
resolved[legacy_role] = role_id
return resolved
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
"""Resolve a legacy workspace role to the current tenant's builtin RBAC role id.
@@ -55,86 +15,26 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
identified by runtime ids, so the command must look them up per tenant.
"""
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
expected_builtin_tag = {
TenantAccountRole.OWNER.value: "owner",
TenantAccountRole.ADMIN.value: "admin",
TenantAccountRole.EDITOR.value: "editor",
TenantAccountRole.NORMAL.value: "normal",
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
}.get(legacy_role)
if not expected_builtin_tag:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
def _iter_tenant_member_batches(
tenant_id: str | None,
*,
db_batch_size: int,
api_batch_size: int,
) -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
"""Yield legacy member roles in tenant-scoped API-sized batches.
Rows are projected to primitive values and streamed from the database, so
the command never materializes every TenantAccountJoin ORM object. The
iterator only keeps one tenant's API-sized batches in memory while it
finds that tenant's owner account.
"""
with session_factory.create_session() as session:
stmt = (
select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role)
.order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
.execution_options(yield_per=db_batch_size)
)
if tenant_id:
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
current_tenant_id: str | None = None
owner_account_id: str | None = None
batches: list[list[tuple[str, str]]] = []
batch: list[tuple[str, str]] = []
def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
if current_tenant_id is None:
return
if batch:
batches.append(batch.copy())
if not owner_account_id:
raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}")
for item in batches:
yield current_tenant_id, owner_account_id, item
for row in session.execute(stmt):
workspace_id = str(row.tenant_id)
if current_tenant_id is not None and workspace_id != current_tenant_id:
yield from flush_current_tenant()
owner_account_id = None
batches = []
batch = []
current_tenant_id = workspace_id
account_id = str(row.account_id)
role = str(row.role)
if role == TenantAccountRole.OWNER.value:
owner_account_id = account_id
batch.append((account_id, role))
if len(batch) >= api_batch_size:
batches.append(batch)
batch = []
yield from flush_current_tenant()
def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool:
return current_roles_by_account_id.get(account_id) == {role_id}
def _replace_member_role(
tenant_id: str,
operator_account_id: str,
member_account_id: str,
role_id: str,
) -> str:
RBACService.MemberRoles.replace(
roles = RBACService.Roles.list(
tenant_id=tenant_id,
account_id=operator_account_id,
member_account_id=member_account_id,
role_ids=[role_id],
)
return member_account_id
options=ListOption(page_number=1, results_per_page=100),
).data
for role in roles:
if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag:
return str(role.id)
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
@click.command(
@@ -142,16 +42,7 @@ def _replace_member_role(
)
@click.option("--tenant-id", help="Only migrate a single workspace.")
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.")
@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.")
@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.")
def migrate_member_roles_to_rbac(
tenant_id: str | None,
dry_run: bool,
db_batch_size: int,
api_batch_size: int,
workers: int,
) -> None:
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
This is an offline migration command for workspaces that already have
@@ -159,102 +50,63 @@ def migrate_member_roles_to_rbac(
member-role binding store.
"""
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
if workers < 1:
raise click.BadParameter("workers must be >= 1", param_hint="--workers")
tenant_count = 0
scanned_count = 0
skipped_count = 0
migrated_count = 0
current_tenant_id: str | None = None
role_ids_by_legacy_role: dict[str, str] = {}
with session_factory.create_session() as session:
stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
if tenant_id:
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
for workspace_id, owner_account_id, batch in _iter_tenant_member_batches(
tenant_id,
db_batch_size=db_batch_size,
api_batch_size=api_batch_size,
):
scanned_count += len(batch)
if workspace_id != current_tenant_id:
tenant_count += 1
current_tenant_id = workspace_id
role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id)
click.echo(f"tenant={workspace_id}")
joins = list(session.scalars(stmt).all())
current_roles_by_account_id: dict[str, set[str]] = {}
if not dry_run:
current_roles = RBACService.MemberRoles.batch_get(
tenant_id=workspace_id,
account_id=owner_account_id,
member_account_ids=[account_id for account_id, _ in batch],
)
current_roles_by_account_id = {
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
}
replace_jobs: list[tuple[str, str]] = []
for member_account_id, legacy_role in batch:
resolved_role_id = role_ids_by_legacy_role.get(legacy_role)
if not resolved_role_id:
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
if dry_run:
click.echo(
f"tenant={workspace_id} member={member_account_id} "
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
)
continue
if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id):
skipped_count += 1
continue
replace_jobs.append((member_account_id, resolved_role_id))
if replace_jobs:
if workers == 1:
for member_account_id, resolved_role_id in replace_jobs:
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
migrated_count += 1
else:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = [
executor.submit(
_replace_member_role,
workspace_id,
owner_account_id,
member_account_id,
resolved_role_id,
)
for member_account_id, resolved_role_id in replace_jobs
]
for future in as_completed(futures):
future.result()
migrated_count += 1
if scanned_count % 10000 == 0:
click.echo(
f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}",
err=True,
)
if scanned_count == 0:
if not joins:
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
return
owner_account_by_tenant: dict[str, str] = {}
resolved_role_ids: dict[tuple[str, str], str] = {}
migrated_count = 0
for join in joins:
workspace_id = str(join.tenant_id)
member_account_id = str(join.account_id)
legacy_role = str(join.role)
if workspace_id not in owner_account_by_tenant:
owner_join = next(
(
item
for item in joins
if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value
),
None,
)
if not owner_join:
raise ValueError(f"Workspace owner not found for tenant={workspace_id}")
owner_account_by_tenant[workspace_id] = str(owner_join.account_id)
operator_account_id = owner_account_by_tenant[workspace_id]
cache_key = (workspace_id, legacy_role)
if cache_key not in resolved_role_ids:
resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role)
resolved_role_id = resolved_role_ids[cache_key]
click.echo(
f"tenant={workspace_id} member={member_account_id} "
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
)
if dry_run:
continue
RBACService.MemberRoles.replace(
tenant_id=workspace_id,
account_id=operator_account_id,
member_account_id=member_account_id,
role_ids=[resolved_role_id],
)
migrated_count += 1
if dry_run:
click.echo(
click.style(
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
"No RBAC bindings were written.",
fg="yellow",
)
)
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
else:
click.echo(
click.style(
f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, "
f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.",
fg="green",
)
)
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))
+1 -7
View File
@@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
CAN_REPLACE_LOGO: bool = Field(
description="Allow customization of the enterprise logo.",
default=False,
default=True,
)
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
@@ -34,12 +34,6 @@ class EnterpriseFeatureConfig(BaseSettings):
default=False,
)
ENTERPRISE_RBAC_REQUEST_TIMEOUT: int = Field(
ge=1,
description="Maximum timeout in seconds for inner RBAC requests.",
default=30,
)
class EnterpriseTelemetryConfig(BaseSettings):
"""
+27 -145
View File
@@ -1,5 +1,4 @@
import json
from collections.abc import Generator, Sequence
from collections.abc import Sequence
from typing import Any, Literal
from flask_restx import Resource
@@ -24,10 +23,8 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator
from core.workflow.generator.types import WorkflowGenerateErrorCode
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import compact_generate_response
from libs.login import login_required
from models import App
from services.workflow_generator_service import WorkflowGeneratorService
@@ -67,10 +64,7 @@ class WorkflowGeneratePayload(BaseModel):
can reuse its existing handler.
"""
mode: Literal["workflow", "advanced-chat", "auto"] = Field(
...,
description="Target app mode for the generated graph; 'auto' lets the backend classify the instruction",
)
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
instruction: str = Field(..., description="Natural-language workflow description")
ideal_output: str = Field(default="", description="Optional sample output for grounding")
model_config_data: ModelConfig = Field(
@@ -84,19 +78,6 @@ class WorkflowGeneratePayload(BaseModel):
)
class WorkflowInstructionSuggestionsPayload(BaseModel):
"""Payload for the workflow-generator instruction-suggestions endpoint.
Runs before the user picks a model, so the suggestions come from the
tenant's default model. The underlying generator never raises — an empty
``suggestions`` list is a valid 200 (soft-fail).
"""
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the suggestions")
language: str | None = Field(default=None, description="Optional language to write the suggestions in")
count: int = Field(default=4, ge=1, le=6, description="Number of suggestions to return (1-6)")
class GeneratorResponse(RootModel[Any]):
root: Any
@@ -110,7 +91,6 @@ register_schema_models(
InstructionGeneratePayload,
InstructionTemplatePayload,
WorkflowGeneratePayload,
WorkflowInstructionSuggestionsPayload,
ModelConfig,
)
register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse)
@@ -333,34 +313,6 @@ class InstructionGenerationTemplateApi(Resource):
raise ValueError(f"Invalid type: {args.type}")
def _workflow_instruction_guard(args: WorkflowGeneratePayload) -> tuple[dict, int] | None:
"""Shared boundary guard for the workflow-generate endpoints.
Returns a ``(body, 400)`` tuple when the instruction is empty / whitespace
or either free-text field exceeds the cap, else ``None``. Pydantic only
validates the field is a str; a whitespace-only or pasted-document input
would otherwise waste a slow planner+builder roundtrip on a response the
validator rejects anyway. Both the blocking and streaming endpoints call
this so they reject identical inputs.
"""
if not args.instruction.strip():
return {
"error": "Instruction is required",
"errors": [{"code": WorkflowGenerateErrorCode.EMPTY_INSTRUCTION, "detail": "Instruction is required"}],
}, 400
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
return {
"error": "Instruction is too long",
"errors": [
{
"code": WorkflowGenerateErrorCode.INSTRUCTION_TOO_LONG,
"detail": f"Instruction and ideal output must each be at most {_MAX_INSTRUCTION_LENGTH} characters",
}
],
}, 400
return None
@console_ns.route("/workflow-generate")
class WorkflowGenerateApi(Resource):
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
@@ -383,11 +335,31 @@ class WorkflowGenerateApi(Resource):
def post(self, current_tenant_id: str):
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
# Reject empty / over-length instructions at the boundary (shared with
# the streaming endpoint) before spending a planner+builder roundtrip.
guard = _workflow_instruction_guard(args)
if guard is not None:
return guard
# Reject obviously-empty instructions at the boundary — Pydantic only
# validates ``instruction`` is a str, but a whitespace-only string
# would still hit the LLM and waste a planner+builder roundtrip on a
# response that the postprocess validator would reject anyway.
if not args.instruction.strip():
return {
"error": "Instruction is required",
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
}, 400
# Bound the prompt at the boundary too: an arbitrarily long
# instruction (or pasted document) blows the planner/builder context
# window and fails with an opaque provider error after two slow LLM
# calls. The cap matches the frontend textarea's maxLength.
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
return {
"error": "Instruction is too long",
"errors": [
{
"code": "INSTRUCTION_TOO_LONG",
"detail": f"Instruction and ideal output must each be at most "
f"{_MAX_INSTRUCTION_LENGTH} characters",
}
],
}, 400
try:
result = WorkflowGeneratorService.generate_workflow_graph(
@@ -408,93 +380,3 @@ class WorkflowGenerateApi(Resource):
raise CompletionRequestError(e.description)
return result
@console_ns.route("/workflow-generate/suggestions")
class WorkflowInstructionSuggestionsApi(Resource):
"""Suggest short, buildable example instructions for the cmd+k generator.
Runs before a model is selected (uses the tenant's default model). The
underlying generator never raises, so an empty list is a valid 200 — the
frontend renders "no suggestions" rather than an error, so no provider-error
mapping is needed here.
"""
@console_ns.doc("generate_workflow_instruction_suggestions")
@console_ns.doc(description="Suggest example workflow-generator instructions for the tenant")
@console_ns.expect(console_ns.models[WorkflowInstructionSuggestionsPayload.__name__])
@console_ns.response(200, "Suggestions generated successfully", console_ns.models[GeneratorResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = WorkflowInstructionSuggestionsPayload.model_validate(console_ns.payload)
suggestions = LLMGenerator.generate_workflow_instruction_suggestions(
tenant_id=current_tenant_id,
mode=args.mode,
language=args.language,
count=args.count,
)
return {"suggestions": suggestions}
@console_ns.route("/workflow-generate/stream")
class WorkflowGenerateStreamApi(Resource):
"""Plan-first streaming variant of ``/workflow-generate`` (Server-Sent Events).
Emits a ``plan`` event (high-level node list + app metadata) as soon as the
planner returns, then a final ``result`` event with the full graph — the
SAME envelope ``/workflow-generate`` returns. Provider-init / invoke errors
are surfaced as a single ``result`` event (code ``MODEL_ERROR``) so the
frontend's stream parser always receives a result rather than a non-SSE HTTP
error.
"""
@console_ns.doc("generate_workflow_graph_stream")
@console_ns.doc(description="Stream a Dify workflow graph (plan then result) via SSE")
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
@console_ns.response(200, "Server-Sent Events stream of plan/result events")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
# Same boundary guards as the blocking endpoint — return a normal 400
# JSON for these BEFORE opening the stream.
guard = _workflow_instruction_guard(args)
if guard is not None:
return guard
def generate() -> Generator[str, None, None]:
try:
for event_name, payload in WorkflowGeneratorService.generate_workflow_graph_stream(
tenant_id=current_tenant_id,
mode=args.mode,
instruction=args.instruction,
model_config=args.model_config_data,
ideal_output=args.ideal_output,
current_graph=args.current_graph,
):
body = {"event": event_name, **payload}
yield f"data: {json.dumps(body)}\n\n"
except (ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError, InvokeError) as e:
# The model instance is resolved inside the service (lazily, on
# first iteration), so a provider / init error surfaces here.
# Emit it as a single SSE result event rather than a non-SSE
# error response so the frontend's stream parser always gets a
# result it can render.
detail = getattr(e, "description", None) or str(e) or "Model invocation failed"
error_body = {
"event": "result",
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0.0, "y": 0.0, "zoom": 0.7}},
"error": detail,
"errors": [{"code": WorkflowGenerateErrorCode.MODEL_ERROR, "detail": detail}],
}
yield f"data: {json.dumps(error_body)}\n\n"
return compact_generate_response(generate())
@@ -8,7 +8,6 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from controllers.common.controller_schemas import WorkflowUpdatePayload
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
@@ -97,7 +96,6 @@ register_schema_models(
SnippetLoopNodeRunPayload,
SnippetWorkflowListQuery,
WorkflowRunQuery,
WorkflowUpdatePayload,
PublishWorkflowPayload,
)
register_response_schema_models(
@@ -167,6 +165,7 @@ class SnippetDraftWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get draft workflow for snippet."""
snippet_service = _snippet_service()
@@ -235,6 +234,7 @@ class SnippetDraftConfigApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get snippet draft workflow configuration limits."""
return {
@@ -256,6 +256,7 @@ class SnippetPublishedWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get published workflow for snippet."""
if not snippet.is_published:
@@ -320,6 +321,7 @@ class SnippetDefaultBlockConfigsApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get default block configurations for snippet workflow."""
snippet_service = _snippet_service()
@@ -342,9 +344,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, snippet: CustomizedSnippet):
"""Get all published workflow versions for snippet."""
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
@@ -413,49 +413,6 @@ class SnippetDraftWorkflowRestoreApi(Resource):
}
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>")
class SnippetWorkflowByIdApi(Resource):
@console_ns.doc("update_snippet_workflow_by_id")
@console_ns.doc(description="Update published snippet workflow attributes")
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
@console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__])
@console_ns.response(200, "Workflow updated successfully", console_ns.models[SnippetWorkflowResponse.__name__])
@console_ns.response(400, "No valid fields to update")
@console_ns.response(404, "Workflow not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def patch(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str):
"""Update a published snippet workflow version's display metadata."""
payload = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
update_data = payload.model_dump(exclude_unset=True)
if not update_data:
return {"message": "No valid fields to update"}, 400
snippet_service = _snippet_service()
with _snippet_session_maker().begin() as session:
workflow = snippet_service.update_workflow(
session=session,
snippet=snippet,
workflow_id=workflow_id,
account=current_user,
data=update_data,
)
if not workflow:
raise NotFound("Workflow not found")
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
response["input_fields"] = snippet.input_fields_list
return response
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
class SnippetWorkflowRunsApi(Resource):
@console_ns.doc("list_snippet_workflow_runs")
@@ -557,6 +514,9 @@ class SnippetDraftNodeRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a single node in snippet draft workflow.
@@ -645,6 +605,9 @@ class SnippetDraftRunIterationNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow iteration node for snippet.
@@ -690,6 +653,9 @@ class SnippetDraftRunLoopNodeApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
"""
Run a draft workflow loop node for snippet.
@@ -733,6 +699,9 @@ class SnippetDraftWorkflowRunApi(Resource):
@with_current_user
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, current_user: Account, snippet: CustomizedSnippet):
"""
Run draft workflow for snippet.
@@ -771,6 +740,9 @@ class SnippetWorkflowTaskStopApi(Resource):
@account_initialization_required
@get_snippet
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def post(self, snippet: CustomizedSnippet, task_id: str):
"""
Stop a running snippet workflow task.
@@ -34,8 +34,11 @@ from controllers.console.app.workflow_draft_variable import (
)
from controllers.console.snippets.snippet_workflow import get_snippet
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_user,
)
@@ -102,6 +105,7 @@ class SnippetWorkflowVariableCollectionApi(Resource):
)
@_snippet_draft_var_prerequisite
@marshal_with(workflow_draft_variable_list_without_value_model)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
@@ -125,6 +129,9 @@ class SnippetWorkflowVariableCollectionApi(Resource):
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
@console_ns.response(204, "Workflow variables deleted successfully")
@_snippet_draft_var_prerequisite
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response:
draft_var_srv = WorkflowDraftVariableService(session=db.session())
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)
+1 -36
View File
@@ -4,17 +4,12 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.wraps import enforce_rbac_access
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
setup_required,
@@ -23,10 +18,9 @@ from controllers.console.wraps import (
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import current_account_with_tenant, login_required
from libs.login import login_required
from models import Account
from models.enums import TagType
from models.model import Tag
from services.tag_service import (
SaveTagPayload,
TagBindingCreatePayload,
@@ -97,30 +91,6 @@ register_schema_models(
register_response_schema_models(console_ns, SimpleResultResponse)
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
if tag_type != TagType.SNIPPET:
return
if not dify_config.RBAC_ENABLED:
return
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
if not dify_config.RBAC_ENABLED:
return
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id).limit(1))
_enforce_snippet_tag_rbac_if_needed(tag_type)
@console_ns.route("/tags")
class TagListApi(Resource):
@setup_required
@@ -152,7 +122,6 @@ class TagListApi(Resource):
raise Forbidden()
payload = TagBasePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
response = TagResponse.model_validate(
@@ -177,7 +146,6 @@ class TagUpdateDeleteApi(Resource):
raise Forbidden()
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
@@ -196,7 +164,6 @@ class TagUpdateDeleteApi(Resource):
def delete(self, tag_id: UUID):
tag_id_str = str(tag_id)
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
TagService.delete_tag(tag_id_str, db.session)
return "", 204
@@ -217,7 +184,6 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingPayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.save_tag_binding(
TagBindingCreatePayload(
tag_ids=payload.tag_ids,
@@ -233,7 +199,6 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
_require_tag_binding_edit_permission(current_user)
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
_enforce_snippet_tag_rbac_if_needed(payload.type)
TagService.delete_tag_binding(
TagBindingDeletePayload(
tag_ids=payload.tag_ids,
@@ -455,6 +455,9 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
)
@with_current_tenant_id
def post(self, current_tenant_id: str, snippet_id: str):
"""Increment snippet use count when it is inserted into a workflow."""
+2 -3
View File
@@ -12,7 +12,6 @@ from flask_restx import Resource
from flask_restx.utils import merge
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from configs import dify_config
@@ -270,8 +269,8 @@ def cloud_edition_billing_rate_limit_check[**P, R](
subscription_plan=knowledge_rate_limit.subscription_plan,
operation="knowledge",
)
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add(rate_limit_log)
db.session.add(rate_limit_log)
db.session.commit()
raise Forbidden(
"Sorry, you have reached the knowledge base request rate limit of your subscription."
)
+1 -212
View File
@@ -2,7 +2,7 @@ import json
import logging
import re
from collections.abc import Sequence
from typing import Any, Literal, NotRequired, Protocol, TypedDict, cast
from typing import Any, NotRequired, Protocol, TypedDict, cast
import json_repair
from sqlalchemy import select
@@ -69,53 +69,6 @@ def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[
return normalized_parameters, stop
# ── Workflow instruction-suggestion tuning ────────────────────────────────
# Suggestions are a soft, pre-model-pick enhancement: short, buildable example
# instructions proposed from the tenant's DEFAULT model. Every failure path
# degrades to an empty list, never an error.
_SUGGESTION_MIN_COUNT = 1
_SUGGESTION_MAX_COUNT = 6
_SUGGESTION_MAX_TOKENS = 512
_SUGGESTION_TEMPERATURE = 0.8
# Bound the grounding context so the prompt stays small regardless of how many
# knowledge bases / tools the tenant has installed.
_SUGGESTION_KB_LIMIT = 10
_SUGGESTION_TOOL_SAMPLE_LINES = 20
_SUGGESTION_SYSTEM_PROMPT = (
"You help a user start building a Dify app by proposing example build instructions. "
"Each suggestion must be a SHORT (at most 8 words), concrete, and BUILDABLE instruction "
"describing an app to generate for the given app type. Make the suggestions diverse — cover "
"different use cases. When the listed knowledge bases or installed tools fit a suggestion, "
"prefer them, but NEVER invent tools or knowledge bases that are not listed. "
"Reply with ONLY a JSON array of strings and nothing else."
)
def _parse_string_list(text: str) -> list[str]:
"""Extract a JSON array of strings from a (possibly noisy) LLM response.
Slices the first ``[...]`` span so surrounding prose / markdown fences are
tolerated, parses it with ``json`` and falls back to ``json_repair``, then
keeps only ``str`` items. Returns ``[]`` on any failure so callers can
treat parsing as best-effort.
"""
match = re.search(r"\[.*\]", text.strip(), re.DOTALL)
if not match:
return []
raw = match.group(0)
try:
parsed = json.loads(raw)
except Exception:
try:
parsed = json_repair.loads(raw)
except Exception:
return []
if not isinstance(parsed, list):
return []
return [item for item in parsed if isinstance(item, str)]
class WorkflowServiceInterface(Protocol):
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
pass
@@ -284,170 +237,6 @@ class LLMGenerator:
return questions
@classmethod
def generate_workflow_instruction_suggestions(
cls,
tenant_id: str,
*,
mode: Literal["workflow", "advanced-chat"],
language: str | None = None,
count: int = 4,
) -> list[str]:
"""Propose short, buildable example instructions for the workflow generator.
Runs BEFORE the user picks a model, so it uses the tenant's DEFAULT LLM
only. Suggestions are a soft enhancement, never a blocker: every failure
path (no default model, KB / tool lookup error, LLM error, unparseable
output) is swallowed and surfaced as an empty list — a valid result the
caller renders as "no suggestions". This method NEVER raises.
"""
count = max(_SUGGESTION_MIN_COUNT, min(count, _SUGGESTION_MAX_COUNT))
try:
model_instance = ModelManager.for_tenant(tenant_id=tenant_id).get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
except Exception:
logger.info("Workflow instruction suggestions: no default model for tenant %s", tenant_id)
return []
context_block = cls._build_suggestion_context(tenant_id)
app_type_label = (
"Workflow — single-shot automation" if mode == "workflow" else "Chatflow — conversational multi-turn"
)
user_lines = [
f"App type: {app_type_label}",
context_block,
f"Return exactly {count} distinct ideas as a JSON array of strings.",
]
if language:
user_lines.append(f"Write every idea in this language: {language}.")
user_prompt = "\n".join(line for line in user_lines if line)
prompt_messages: list[PromptMessage] = [
SystemPromptMessage(content=_SUGGESTION_SYSTEM_PROMPT),
UserPromptMessage(content=user_prompt),
]
try:
response: LLMResult = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters={"max_tokens": _SUGGESTION_MAX_TOKENS, "temperature": _SUGGESTION_TEMPERATURE},
stream=False,
)
except Exception:
logger.exception("Workflow instruction suggestions: LLM invocation failed")
return []
raw_suggestions = _parse_string_list(response.message.get_text_content() or "")
# Strip whitespace + surrounding quotes, drop empties, dedupe
# case-insensitively (preserving first-seen casing), cap to ``count``.
cleaned: list[str] = []
seen: set[str] = set()
for item in raw_suggestions:
idea = item.strip().strip("\"'").strip()
if not idea:
continue
key = idea.casefold()
if key in seen:
continue
seen.add(key)
cleaned.append(idea)
if len(cleaned) >= count:
break
return cleaned
@staticmethod
def _build_suggestion_context(tenant_id: str) -> str:
"""Assemble an optional grounding block naming the tenant's KBs and tools.
Best-effort: each section is isolated in its own try/except so a failure
enumerating one (DB hiccup, plugin daemon down) never blocks the other
or the suggestion call itself. Returns "" when nothing is available.
"""
sections: list[str] = []
try:
from models.dataset import Dataset
names = db.session.scalars(
select(Dataset.name)
.where(Dataset.tenant_id == tenant_id)
.order_by(Dataset.created_at.desc())
.limit(_SUGGESTION_KB_LIMIT)
).all()
kb_names = [name for name in names if name]
if kb_names:
sections.append("Knowledge bases:\n" + "\n".join(f"- {name}" for name in kb_names))
except Exception:
logger.info("Workflow instruction suggestions: failed to load knowledge bases", exc_info=True)
try:
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue
tool_text = format_tool_catalogue(build_tool_catalogue(tenant_id))
if tool_text:
sample = "\n".join(tool_text.splitlines()[:_SUGGESTION_TOOL_SAMPLE_LINES])
sections.append("Installed tools:\n" + sample)
except Exception:
logger.info("Workflow instruction suggestions: failed to load tool catalogue", exc_info=True)
if not sections:
return ""
return "\n\n".join(sections) + "\n\n"
@classmethod
def classify_workflow_mode(
cls,
tenant_id: str,
instruction: str,
model_config: ModelConfig,
) -> Literal["workflow", "advanced-chat"]:
"""Classify a free-text instruction into a concrete app mode.
One tiny LLM call using the model the user already picked (so no extra
provider setup is needed). Parsed leniently; defaults to
``advanced-chat`` on anything unexpected or any error, so a
``mode="auto"`` request never blocks generation. NEVER raises.
"""
default_mode: Literal["workflow", "advanced-chat"] = "advanced-chat"
try:
model_instance = ModelManager.for_tenant(tenant_id=tenant_id).get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=model_config.provider,
model=model_config.name,
)
prompt_messages: list[PromptMessage] = [
UserPromptMessage(
content=(
"Reply with exactly one word: 'workflow' (one-shot automation, no chat) "
"or 'advanced-chat' (conversational multi-turn). "
f"Instruction: {instruction.strip()}"
)
),
]
response: LLMResult = model_instance.invoke_llm(
prompt_messages=prompt_messages,
model_parameters={"max_tokens": 4, "temperature": 0},
stream=False,
)
text = (response.message.get_text_content() or "").strip().lower()
except Exception:
logger.info("Workflow mode classification failed; defaulting to %s", default_mode, exc_info=True)
return default_mode
# Lenient parse: an affirmative "workflow" wins; everything else
# (including a truncated / empty / garbled reply) falls back to the
# conversational default. "advanced-chat" needs no positive match
# because it IS the default.
if "workflow" in text:
return "workflow"
return default_mode
@classmethod
def generate_rule_config(cls, tenant_id: str, args: RuleGeneratePayload):
output_parser = RuleConfigGeneratorOutputParser()
+1 -16
View File
@@ -1,13 +1,10 @@
import logging
import re
from collections.abc import Callable
from contextlib import AbstractContextManager, ExitStack
from types import TracebackType
from typing import Any
from urllib.parse import urlparse
from flask import has_request_context, request
from core.mcp.client.sse_client import sse_client
from core.mcp.client.streamable_client import streamablehttp_client
from core.mcp.error import MCPConnectionError
@@ -26,22 +23,10 @@ class MCPClient:
sse_read_timeout: float | None = None,
):
self.server_url = server_url
self.headers = headers.copy() if headers else {}
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
# Substitute placeholders with incoming request headers if in a request context
if has_request_context() and self.headers:
pattern = re.compile(r"\{\{\s*request\.headers?\.(.+?)\s*\}\}", re.IGNORECASE)
for key, value in list(self.headers.items()):
if isinstance(value, str):
def replace_func(match):
header_name = match.group(1)
return request.headers.get(header_name, "")
self.headers[key] = pattern.sub(replace_func, value)
# Initialize session and client objects
self._session: ClientSession | None = None
self._exit_stack = ExitStack()
+4 -14
View File
@@ -1030,10 +1030,6 @@ class DatasetRetrieval:
):
"""
Persist dataset query audit rows for retrieval requests.
Query audit logging is a side effect of retrieval. Keep it in an
independent transaction so failures or commits here do not affect the
request/workflow transaction that called the retriever.
"""
if not query and not attachment_ids:
return
@@ -1045,9 +1041,6 @@ class DatasetRetrieval:
app_id,
)
return
created_by_role = self._resolve_creator_user_role(user_from)
if created_by_role is None:
return
dataset_queries = []
for dataset_id in dataset_ids:
contents = []
@@ -1062,16 +1055,13 @@ class DatasetRetrieval:
content=json.dumps(contents),
source=DatasetQuerySource.APP,
source_app_id=app_id,
created_by_role=created_by_role,
created_by_role=CreatorUserRole(user_from),
created_by=created_by,
)
dataset_queries.append(dataset_query)
if not dataset_queries:
return
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
session.add_all(dataset_queries)
if dataset_queries:
db.session.add_all(dataset_queries)
db.session.commit()
def _retriever(
self,
+36 -39
View File
@@ -7,7 +7,6 @@ from datetime import UTC, datetime
from mimetypes import guess_type
from typing import Any, Union, cast
from sqlalchemy.orm import sessionmaker
from yarl import URL
from core.app.entities.app_invoke_entities import InvokeFrom
@@ -339,49 +338,47 @@ class ToolEngine:
user_id: str,
) -> list[str]:
"""
Create message files produced by a tool call.
Tool file persistence is a side effect of agent execution. Use an
independent transaction so this helper never commits or closes the
caller's request-scoped session.
Create message file
:return: message file ids
"""
result = []
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
for message in tool_messages:
# extract tool file id from url
tool_file_id = message.url.split("/")[-1].split(".")[0]
message_file = MessageFile(
message_id=agent_message.id,
type=ToolEngine._resolve_tool_file_type(message),
transfer_method=FileTransferMethod.TOOL_FILE,
belongs_to=MessageFileBelongsTo.ASSISTANT,
url=message.url,
upload_file_id=tool_file_id,
created_by_role=(
CreatorUserRole.ACCOUNT
if invoke_from in {InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER}
else CreatorUserRole.END_USER
),
created_by=user_id,
)
for message in tool_messages:
if "image" in message.mimetype:
file_type = FileType.IMAGE
elif "video" in message.mimetype:
file_type = FileType.VIDEO
elif "audio" in message.mimetype:
file_type = FileType.AUDIO
elif "text" in message.mimetype or "pdf" in message.mimetype:
file_type = FileType.DOCUMENT
else:
file_type = FileType.CUSTOM
session.add(message_file)
result.append(message_file.id)
# extract tool file id from url
tool_file_id = message.url.split("/")[-1].split(".")[0]
message_file = MessageFile(
message_id=agent_message.id,
type=file_type,
transfer_method=FileTransferMethod.TOOL_FILE,
belongs_to=MessageFileBelongsTo.ASSISTANT,
url=message.url,
upload_file_id=tool_file_id,
created_by_role=(
CreatorUserRole.ACCOUNT
if invoke_from in {InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER}
else CreatorUserRole.END_USER
),
created_by=user_id,
)
db.session.add(message_file)
db.session.commit()
db.session.refresh(message_file)
result.append(message_file.id)
db.session.close()
return result
@staticmethod
def _resolve_tool_file_type(message: ToolInvokeMessageBinary) -> FileType:
if "image" in message.mimetype:
return FileType.IMAGE
elif "video" in message.mimetype:
return FileType.VIDEO
elif "audio" in message.mimetype:
return FileType.AUDIO
elif "text" in message.mimetype or "pdf" in message.mimetype:
return FileType.DOCUMENT
else:
return FileType.CUSTOM
+7 -157
View File
@@ -27,7 +27,6 @@ import json
import logging
import re
import time
from collections.abc import Iterator
from typing import Any, ClassVar, cast
import json_repair
@@ -186,48 +185,6 @@ def _result_with_errors(
return base
def _with_mode(result: WorkflowGenerateResultDict, mode: WorkflowGenerationMode) -> WorkflowGenerateResultDict:
"""Stamp the resolved concrete ``mode`` onto a result envelope.
``mode="auto"`` requests are resolved to a concrete mode before planning;
echoing it back lets the frontend pick the right app type to create. It's
present for explicit modes too so the response shape stays uniform.
"""
result["mode"] = mode
return result
def _build_plan_event(
*,
plan: PlannerResultDict,
plan_nodes: list[dict[str, Any]],
start_inputs: list[dict[str, Any]],
mode: WorkflowGenerationMode,
) -> dict[str, Any]:
"""Shape the ``plan`` event emitted before the (slower) builder runs.
Node fields are pulled defensively: the planner schema only guarantees
``node_type`` is present, so ``label`` / ``purpose`` may be missing on a
terse plan and default to empty strings.
"""
return {
"title": str(plan.get("title") or ""),
"description": str(plan.get("description") or ""),
"app_name": str(plan.get("app_name") or "").strip(),
"icon": str(plan.get("icon") or "").strip(),
"mode": mode,
"nodes": [
{
"label": str(node.get("label") or ""),
"node_type": str(node.get("node_type") or ""),
"purpose": str(node.get("purpose") or ""),
}
for node in plan_nodes
],
"start_inputs": start_inputs,
}
def _stage_error_to_envelope_code(exc: Exception) -> str:
"""Map a stage-typed exception to the result envelope's error code."""
if isinstance(exc, _StageJSONError):
@@ -293,100 +250,6 @@ class WorkflowGenerator:
``errors`` and keep the previous version visible.
"""
# Consume the shared event generator and keep only the final result
# envelope — ``generate_workflow_graph_stream`` shares the exact same
# pipeline so the two stay behaviourally identical. The plan event is
# ignored here.
result: WorkflowGenerateResultDict | None = None
for event_name, payload in cls._iter_generation_events(
model_instance=model_instance,
model_parameters=model_parameters,
provider=provider,
model_name=model_name,
model_mode=model_mode,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
installed_tools=installed_tools,
current_graph=current_graph,
):
if event_name == "result":
result = cast(WorkflowGenerateResultDict, payload)
# The event generator always emits exactly one result envelope; this
# fallback only guards against a future refactor that forgets to.
if result is None:
result = _with_mode(_empty_result(), mode)
return result
@classmethod
def generate_workflow_graph_stream(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationMode,
instruction: str,
ideal_output: str = "",
tool_catalogue_text: str = "",
installed_tools: set[tuple[str, str]] | None = None,
current_graph: dict[str, Any] | None = None,
) -> Iterator[tuple[str, dict[str, Any]]]:
"""
Streaming sibling of ``generate_workflow_graph``.
Yields a ``plan`` event (title / description / app_name / icon / mode /
high-level nodes / start_inputs) as soon as the planner returns, then a
final ``result`` event carrying the SAME envelope dict the non-streaming
method returns (graph / message / app_name / icon / error / errors /
mode, plus structural errors when any). On a planner / empty-plan /
builder failure only the ``result`` event is emitted — no ``plan``.
"""
yield from cls._iter_generation_events(
model_instance=model_instance,
model_parameters=model_parameters,
provider=provider,
model_name=model_name,
model_mode=model_mode,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
installed_tools=installed_tools,
current_graph=current_graph,
)
@classmethod
def _iter_generation_events(
cls,
*,
model_instance,
model_parameters: dict[str, Any],
provider: str,
model_name: str,
model_mode: str,
mode: WorkflowGenerationMode,
instruction: str,
ideal_output: str = "",
tool_catalogue_text: str = "",
installed_tools: set[tuple[str, str]] | None = None,
current_graph: dict[str, Any] | None = None,
) -> Iterator[tuple[str, dict[str, Any]]]:
"""
Drive planner → builder → postprocess and yield generation events.
Shared core for both ``generate_workflow_graph`` (keeps only the final
``result``) and ``generate_workflow_graph_stream`` (streams every
event). Emits at most one ``plan`` event — only once the planner
produced a non-empty plan — followed by exactly one ``result`` event.
On a planner / empty-plan / builder failure it emits only the
``result`` event carrying the error envelope. Every result envelope is
stamped with the resolved concrete ``mode``.
"""
# ── 1. PLANNER ────────────────────────────────────────────────────
plan, plan_err = cls._run_stage(
stage="Planner",
@@ -402,22 +265,16 @@ class WorkflowGenerator:
),
)
if plan_err is not None:
yield "result", cast(dict[str, Any], _with_mode(_result_with_errors(_empty_result(), [plan_err]), mode))
return
return _result_with_errors(_empty_result(), [plan_err])
# The lambda return is non-None when no error fired — narrow it for type-checkers.
plan = cast(PlannerResultDict, plan)
plan_nodes: list[dict[str, Any]] = cast(list[dict[str, Any]], plan.get("nodes", []))
if not plan_nodes:
empty_plan = _with_mode(
_result_with_errors(
_empty_result(),
[_err(WorkflowGenerateErrorCode.EMPTY_PLAN, "Planner returned no nodes")],
),
mode,
return _result_with_errors(
_empty_result(),
[_err(WorkflowGenerateErrorCode.EMPTY_PLAN, "Planner returned no nodes")],
)
yield "result", cast(dict[str, Any], empty_plan)
return
# Planner-supplied user-input declarations. The builder uses these to
# populate ``start.data.variables`` so downstream ``{#start.<var>#}``
@@ -429,10 +286,6 @@ class WorkflowGenerator:
if isinstance(item, dict) and (item.get("variable") or "").strip()
]
# First event the stream sees: the high-level plan, before the slower
# builder call. Non-streaming callers ignore it.
yield "plan", _build_plan_event(plan=plan, plan_nodes=plan_nodes, start_inputs=start_inputs, mode=mode)
# ── 2. BUILDER ────────────────────────────────────────────────────
graph, build_err = cls._run_stage(
stage="Builder",
@@ -453,8 +306,7 @@ class WorkflowGenerator:
),
)
if build_err is not None:
yield "result", cast(dict[str, Any], _with_mode(_result_with_errors(_empty_result(), [build_err]), mode))
return
return _result_with_errors(_empty_result(), [build_err])
graph = cast(GraphDict, graph)
# ── 3. POSTPROC + VALIDATE ────────────────────────────────────────
@@ -470,7 +322,6 @@ class WorkflowGenerator:
"error": "",
"errors": [],
}
_with_mode(result, mode)
# Final structural sanity check — fail closed if start/end shape is
# wrong, container topology is broken, a tool was hallucinated, or a
@@ -479,9 +330,8 @@ class WorkflowGenerator:
structural_errors = cls._validate_structure(graph=graph, mode=mode, installed_tools=installed_tools)
if structural_errors:
logger.warning("Workflow generator: structural validation failed: %s", structural_errors)
yield "result", cast(dict[str, Any], _result_with_errors(result, structural_errors))
return
yield "result", cast(dict[str, Any], result)
return _result_with_errors(result, structural_errors)
return result
@classmethod
def _run_stage(
-11
View File
@@ -11,13 +11,6 @@ from typing import Final, Literal, NotRequired, TypedDict
WorkflowGenerationMode = Literal["workflow", "advanced-chat"]
# The mode accepted at the API boundary. ``auto`` is a sentinel that asks the
# service to classify the instruction into a concrete ``WorkflowGenerationMode``
# (one tiny LLM call) BEFORE planning — see
# ``WorkflowGeneratorService._resolve_mode`` and
# ``LLMGenerator.classify_workflow_mode``.
WorkflowGenerationModeRequest = Literal["workflow", "advanced-chat", "auto"]
# Machine-readable error codes returned in ``WorkflowGenerateResultDict.errors``.
# Frontend maps these to localised copy via ``workflow.generator.errors.<code>``
@@ -155,7 +148,3 @@ class WorkflowGenerateResultDict(TypedDict):
icon: str
error: str
errors: list[WorkflowGenerateErrorDict]
# Resolved concrete generation mode ("workflow" / "advanced-chat"). Stamped
# onto every envelope so a ``mode="auto"`` request can tell the frontend
# which app type to create; present for explicit modes too for uniformity.
mode: NotRequired[str]
@@ -45,52 +45,34 @@ def upgrade():
# PostgreSQL 18's `uuidv7` function. This capability is rarely needed in practice, as IDs can be
# generated and controlled within the application layer.
conn = op.get_bind()
if _is_pg(conn):
# PostgreSQL: Create uuidv7 functions.
# PostgreSQL 18 ships a native pg_catalog.uuidv7(), so only create our own
# implementation when the server does not already provide one. Otherwise the
# CREATE FUNCTION below and the unqualified COMMENT statement collide with the
# built-in and the migration fails.
#
# The existence check is done server-side via a DO block rather than
# conn.execute().scalar() because the latter returns None in offline
# migration mode (no real database connection), causing an AttributeError.
# PostgreSQL: Create uuidv7 functions
op.execute(sa.text(r"""
DO $do$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE p.proname = 'uuidv7' AND n.nspname = 'pg_catalog'
) THEN
/* Main function to generate a uuidv7 value with millisecond precision */
CREATE FUNCTION public.uuidv7() RETURNS uuid
AS
$func$
-- Replace the first 48 bits of a uuidv4 with the current
-- number of milliseconds since 1970-01-01 UTC
-- and set the "ver" field to 7 by setting additional bits
SELECT encode(
/* Main function to generate a uuidv7 value with millisecond precision */
CREATE FUNCTION uuidv7() RETURNS uuid
AS
$$
-- Replace the first 48 bits of a uuidv4 with the current
-- number of milliseconds since 1970-01-01 UTC
-- and set the "ver" field to 7 by setting additional bits
SELECT encode(
set_bit(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid()) placing
substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
3)
from 1 for 6),
52, 1),
53, 1), 'hex')::uuid;
$func$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
overlay(uuid_send(gen_random_uuid()) placing
substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
3)
from 1 for 6),
52, 1),
53, 1), 'hex')::uuid;
$$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
COMMENT ON FUNCTION public.uuidv7 IS
'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
END IF;
END
$do$;
COMMENT ON FUNCTION uuidv7 IS
'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
"""))
op.execute(sa.text(r"""
CREATE FUNCTION public.uuidv7_boundary(timestamptz) RETURNS uuid
CREATE FUNCTION uuidv7_boundary(timestamptz) RETURNS uuid
AS
$$
/* uuid fields: version=0b0111, variant=0b10 */
@@ -101,7 +83,7 @@ SELECT encode(
'hex')::uuid;
$$ LANGUAGE SQL STABLE STRICT PARALLEL SAFE;
COMMENT ON FUNCTION public.uuidv7_boundary(timestamptz) IS
COMMENT ON FUNCTION uuidv7_boundary(timestamptz) IS
'Generate a non-random uuidv7 with the given timestamp (first 48 bits) and all random bits to 0. As the smallest possible uuidv7 for that timestamp, it may be used as a boundary for partitions.';
"""
))
@@ -113,10 +95,7 @@ def downgrade():
conn = op.get_bind()
if _is_pg(conn):
# IF EXISTS keeps the downgrade a no-op on PostgreSQL 18, where the native
# pg_catalog.uuidv7() was kept and no public.uuidv7() was created. Scoping the
# drop to the public schema avoids touching the built-in.
op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7()"))
op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)"))
op.execute(sa.text("DROP FUNCTION uuidv7"))
op.execute(sa.text("DROP FUNCTION uuidv7_boundary"))
else:
pass
+1 -73
View File
@@ -8700,32 +8700,6 @@ Reset a draft workflow variable to its default value (snippet scope)
| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)<br> |
| 400 | No draft workflow found | |
### [PATCH] /snippets/{snippet_id}/workflows/{workflow_id}
**Update a published snippet workflow version's display metadata**
Update published snippet workflow attributes
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| snippet_id | path | Snippet ID | Yes | string (uuid) |
| workflow_id | path | Workflow ID | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [WorkflowUpdatePayload](#workflowupdatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow updated successfully | **application/json**: [SnippetWorkflowResponse](#snippetworkflowresponse)<br> |
| 400 | No valid fields to update | |
| 404 | Workflow not found | |
### [POST] /snippets/{snippet_id}/workflows/{workflow_id}/restore
**Restore a published snippet workflow version into the draft workflow**
@@ -9135,38 +9109,6 @@ Generate a Dify workflow graph from natural language
| 400 | Invalid request parameters | |
| 402 | Provider quota exceeded | |
### [POST] /workflow-generate/stream
Stream a Dify workflow graph (plan then result) via SSE
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [WorkflowGeneratePayload](#workflowgeneratepayload)<br> |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Server-Sent Events stream of plan/result events |
| 400 | Invalid request parameters |
### [POST] /workflow-generate/suggestions
Suggest example workflow-generator instructions for the tenant
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [WorkflowInstructionSuggestionsPayload](#workflowinstructionsuggestionspayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Suggestions generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)<br> |
| 400 | Invalid request parameters | |
### [GET] /workflow/{workflow_run_id}/events
**Get workflow execution events stream after resume**
@@ -21194,23 +21136,9 @@ can reuse its existing handler.
| current_graph | object | Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch | No |
| ideal_output | string | Optional sample output for grounding | No |
| instruction | string | Natural-language workflow description | Yes |
| mode | string, <br>**Available values:** "advanced-chat", "auto", "workflow" | Target app mode for the generated graph; 'auto' lets the backend classify the instruction<br>*Enum:* `"advanced-chat"`, `"auto"`, `"workflow"` | Yes |
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | Target app mode for the generated graph<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes |
#### WorkflowInstructionSuggestionsPayload
Payload for the workflow-generator instruction-suggestions endpoint.
Runs before the user picks a model, so the suggestions come from the
tenant's default model. The underlying generator never raises — an empty
``suggestions`` list is a valid 200 (soft-fail).
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| count | integer, <br>**Default:** 4 | Number of suggestions to return (1-6) | No |
| language | string | Optional language to write the suggestions in | No |
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | Target app mode for the suggestions<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
#### WorkflowListQuery
| Name | Type | Description | Required |
@@ -144,7 +144,7 @@ class AnalyticdbVectorBySql:
f"id text PRIMARY KEY,"
f"vector real[], ref_doc_id text, page_content text, metadata_ jsonb, "
f"to_tsvector TSVECTOR"
f") DISTRIBUTED BY (id);"
f") WITH (fillfactor=70) DISTRIBUTED BY (id);"
)
if embedding_dimension is not None:
index_name = f"{self._collection_name}_embedding_idx"
@@ -153,7 +153,7 @@ class AnalyticdbVectorBySql:
cur.execute(
f"CREATE INDEX {index_name} ON {self.table_name} USING ann(vector) "
f"WITH(dim='{embedding_dimension}', distancemeasure='{self.config.metrics}', "
f"pq_enable=0)"
f"pq_enable=0, external_storage=0)"
)
cur.execute(f"CREATE INDEX ON {self.table_name} USING gin(to_tsvector)")
except Exception as e:
+64 -3
View File
@@ -96,6 +96,10 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
ComposerConfigValidator.validate_draft_save_payload(payload)
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
class AgentComposerService:
@classmethod
def load_workflow_composer(
@@ -419,7 +423,11 @@ class AgentComposerService:
account_id_for_audit=account_id,
)
agent.updated_by = account_id
agent.active_config_is_published = False
agent.active_config_is_published = cls._agent_soul_matches_active_config(
tenant_id=tenant_id,
agent=agent,
agent_soul=payload.agent_soul,
)
db.session.commit()
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
@@ -430,6 +438,54 @@ class AgentComposerService:
)
return state
@classmethod
def _agent_soul_matches_active_config(
cls,
*,
tenant_id: str,
agent: Agent,
agent_soul: AgentSoulConfig,
) -> bool:
if not agent.active_config_snapshot_id:
return False
active_version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id,
version_id=agent.active_config_snapshot_id,
)
if not active_version:
return False
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
tenant_id=tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
):
return False
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
@classmethod
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
revisions = db.session.scalars(
select(AgentConfigRevision.operation).where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
AgentConfigRevision.current_snapshot_id == snapshot_id,
)
).all()
return any(
operation
in {
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
for operation in revisions
)
@classmethod
def publish_agent_app_draft(
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
@@ -557,16 +613,21 @@ class AgentComposerService:
)
if build_draft is None:
raise AgentVersionNotFoundError()
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
normal_draft = cls._save_agent_draft(
tenant_id=tenant_id,
agent=agent,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict),
agent_soul=applied_agent_soul,
account_id_for_audit=account_id,
base_snapshot_id=build_draft.base_snapshot_id,
)
agent.active_config_is_published = False
agent.active_config_is_published = cls._agent_soul_matches_active_config(
tenant_id=tenant_id,
agent=agent,
agent_soul=applied_agent_soul,
)
agent.updated_by = account_id
db.session.delete(build_draft)
db.session.commit()
+4 -3
View File
@@ -992,9 +992,10 @@ class AgentRosterService:
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
"""Return each Agent's stored normal-draft publish state.
The flag is maintained by write paths: normal shared draft writes mark it
dirty, while publish/version creation paths mark it clean. User-scoped
debug drafts intentionally do not affect this state.
The flag is maintained by write paths against the normal shared draft:
saves compare the draft content with the active snapshot, while publish
and version creation paths mark the new active snapshot clean.
User-scoped debug drafts intentionally do not affect this state.
"""
agents = [agent for agent in agents if agent.id]
if not agents:
-2
View File
@@ -435,7 +435,6 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.delete",
"app.acl.release_and_version",
"app.acl.monitor",
"app.acl.log_and_annotation",
"app.acl.access_config",
]
@@ -744,7 +743,6 @@ def _inner_call(
account_id=account_id,
json=json,
params=params,
timeout=dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT,
)
-40
View File
@@ -680,46 +680,6 @@ class SnippetService:
return workflows, has_more
def update_workflow(
self,
*,
session: Session,
snippet: CustomizedSnippet,
workflow_id: str,
account: Account,
data: dict[str, Any],
) -> Workflow | None:
"""
Update a published snippet workflow version's display metadata.
:param session: Database session
:param snippet: CustomizedSnippet instance
:param workflow_id: Workflow ID
:param account: Account making the change
:param data: Dictionary containing fields to update
:return: Updated workflow or None if not found
"""
stmt = select(Workflow).where(
Workflow.id == workflow_id,
Workflow.tenant_id == snippet.tenant_id,
Workflow.app_id == snippet.id,
self._snippet_kind_filter(),
Workflow.version != Workflow.VERSION_DRAFT,
)
workflow = session.scalar(stmt)
if not workflow:
return None
allowed_fields = {"marked_name", "marked_comment"}
for field, value in data.items():
if field in allowed_fields:
setattr(workflow, field, value)
workflow.updated_by = account.id
workflow.updated_at = datetime.now(UTC).replace(tzinfo=None)
session.add(workflow)
return workflow
# --- Default Block Configs ---
def get_default_block_configs(self) -> list[dict]:
+6 -11
View File
@@ -40,7 +40,6 @@ from services.errors.app import QuotaExceededError
from services.quota_service import QuotaService
from services.trigger.app_trigger_service import AppTriggerService
from services.workflow.entities import WebhookTriggerData
from services.workflow_service import WorkflowService
try:
import magic
@@ -115,7 +114,6 @@ class WebhookService:
workflow = session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == webhook_trigger.tenant_id,
Workflow.app_id == webhook_trigger.app_id,
Workflow.version == Workflow.VERSION_DRAFT,
)
@@ -127,7 +125,6 @@ class WebhookService:
app_trigger = session.scalar(
select(AppTrigger)
.where(
AppTrigger.tenant_id == webhook_trigger.tenant_id,
AppTrigger.app_id == webhook_trigger.app_id,
AppTrigger.node_id == webhook_trigger.node_id,
AppTrigger.trigger_type == AppTriggerType.TRIGGER_WEBHOOK,
@@ -148,18 +145,16 @@ class WebhookService:
if app_trigger.status != AppTriggerStatus.ENABLED:
raise ValueError(f"Webhook trigger is disabled for webhook {webhook_id}")
app = session.scalar(
select(App)
# Get workflow
workflow = session.scalar(
select(Workflow)
.where(
App.tenant_id == webhook_trigger.tenant_id,
App.id == webhook_trigger.app_id,
Workflow.app_id == webhook_trigger.app_id,
Workflow.version != Workflow.VERSION_DRAFT,
)
.order_by(Workflow.created_at.desc())
.limit(1)
)
if not app:
raise ValueError(f"App not found for webhook {webhook_id}")
workflow = WorkflowService().get_published_workflow(app, session=session)
if not workflow:
raise ValueError(f"Workflow not found for app {webhook_trigger.app_id}")
+1 -1
View File
@@ -333,7 +333,7 @@ class VectorService:
# Add documents to vector store if any
if documents and dataset.is_multimodal:
vector.create_multimodal(documents)
vector.add_texts(documents, duplicate_check=True)
# Single commit for all operations
db.session.commit()
+24 -119
View File
@@ -12,19 +12,13 @@ createApp) rather than from inside another workflow.
"""
import logging
from collections.abc import Iterator
from typing import Any
from core.app.app_config.entities import ModelConfig
from core.llm_generator.llm_generator import LLMGenerator
from core.model_manager import ModelInstance, ModelManager
from core.model_manager import ModelManager
from core.workflow.generator import WorkflowGenerator
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue, installed_tool_keys
from core.workflow.generator.types import (
WorkflowGenerateResultDict,
WorkflowGenerationMode,
WorkflowGenerationModeRequest,
)
from core.workflow.generator.types import WorkflowGenerateResultDict, WorkflowGenerationMode
from graphon.model_runtime.entities.model_entities import ModelType
logger = logging.getLogger(__name__)
@@ -43,7 +37,7 @@ class WorkflowGeneratorService:
cls,
*,
tenant_id: str,
mode: WorkflowGenerationModeRequest,
mode: WorkflowGenerationMode,
instruction: str,
model_config: ModelConfig,
ideal_output: str = "",
@@ -52,12 +46,6 @@ class WorkflowGeneratorService:
"""
Resolve a model instance for the tenant and run the generator.
``mode`` accepts the ``"auto"`` sentinel when set, the instruction is
classified into a concrete ``workflow`` / ``advanced-chat`` mode (one
tiny LLM call) before planning so the rest of the pipeline runs against
a concrete mode. The resolved mode is echoed back under the result's
``mode`` key.
``current_graph`` is the existing draft graph for the cmd+k `/refine`
flow when present the generator refines it instead of creating a new
graph from scratch. ``None`` is the `/create` path.
@@ -66,109 +54,6 @@ class WorkflowGeneratorService:
controller can map them to existing HTTP error envelopes (same
envelope as ``/rule-generate``).
"""
resolved_mode = cls._resolve_mode(
tenant_id=tenant_id, mode=mode, instruction=instruction, model_config=model_config
)
model_instance, model_parameters, tool_catalogue_text, installed_tools = cls._resolve_generation_context(
tenant_id=tenant_id, model_config=model_config
)
return WorkflowGenerator.generate_workflow_graph(
model_instance=model_instance,
model_parameters=model_parameters,
provider=model_config.provider,
model_name=model_config.name,
model_mode=model_config.mode.value,
mode=resolved_mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
installed_tools=installed_tools,
current_graph=current_graph,
)
@classmethod
def generate_workflow_graph_stream(
cls,
*,
tenant_id: str,
mode: WorkflowGenerationModeRequest,
instruction: str,
model_config: ModelConfig,
ideal_output: str = "",
current_graph: dict[str, Any] | None = None,
) -> Iterator[tuple[str, dict[str, Any]]]:
"""
Streaming sibling of ``generate_workflow_graph``.
Resolves the same model instance / tool catalogue / concrete mode, then
delegates to ``WorkflowGenerator.generate_workflow_graph_stream`` and
yields its ``(event_name, payload)`` tuples through to the controller's
SSE writer. Provider-init / invoke errors raised while resolving the
model instance propagate to the caller (the controller emits them as a
single ``result`` SSE event).
"""
resolved_mode = cls._resolve_mode(
tenant_id=tenant_id, mode=mode, instruction=instruction, model_config=model_config
)
model_instance, model_parameters, tool_catalogue_text, installed_tools = cls._resolve_generation_context(
tenant_id=tenant_id, model_config=model_config
)
yield from WorkflowGenerator.generate_workflow_graph_stream(
model_instance=model_instance,
model_parameters=model_parameters,
provider=model_config.provider,
model_name=model_config.name,
model_mode=model_config.mode.value,
mode=resolved_mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
installed_tools=installed_tools,
current_graph=current_graph,
)
@classmethod
def _resolve_mode(
cls,
*,
tenant_id: str,
mode: WorkflowGenerationModeRequest,
instruction: str,
model_config: ModelConfig,
) -> WorkflowGenerationMode:
"""Resolve the request mode into a concrete generation mode.
``"auto"`` triggers a one-word LLM classification using the model the
user already picked; everything else passes through unchanged. The
classifier never raises (defaults to ``advanced-chat``), so ``auto``
never blocks generation.
"""
if mode == "auto":
return LLMGenerator.classify_workflow_mode(
tenant_id=tenant_id, instruction=instruction, model_config=model_config
)
return mode
@classmethod
def _resolve_generation_context(
cls,
*,
tenant_id: str,
model_config: ModelConfig,
) -> tuple[ModelInstance, dict[str, Any], str, set[tuple[str, str]] | None]:
"""Resolve the model instance, completion params, and tool catalogue.
Build the installed-tool catalogue for this tenant so the planner /
builder can pick concrete tools instead of inventing names, AND so the
runner's validator can reject hallucinated tool names BEFORE the user
clicks Apply. A failure here (plugin daemon unreachable, etc.) must not
block generation log and fall back to the no-tool path, which also
disables tool validation in the runner (``None`` sentinel rather than
empty set, so we don't reject every tool node just because we couldn't
enumerate the catalogue).
"""
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
@@ -179,6 +64,14 @@ class WorkflowGeneratorService:
model_parameters: dict[str, Any] = dict(model_config.completion_params or {})
# Build the installed-tool catalogue for this tenant so the planner/
# builder can pick concrete tools instead of inventing names, AND so
# the runner's validator can reject hallucinated tool names BEFORE
# the user clicks Apply. A failure here (plugin daemon unreachable,
# etc.) must not block generation — log and fall back to the no-tool
# path, which also disables tool validation in the runner (None
# sentinel rather than empty set, so we don't reject every tool
# node just because we couldn't enumerate the catalogue).
tool_catalogue_text = ""
installed_tools: set[tuple[str, str]] | None = None
try:
@@ -188,4 +81,16 @@ class WorkflowGeneratorService:
except Exception:
logger.exception("Workflow generator: failed to build tool catalogue for tenant %s", tenant_id)
return model_instance, model_parameters, tool_catalogue_text, installed_tools
return WorkflowGenerator.generate_workflow_graph(
model_instance=model_instance,
model_parameters=model_parameters,
provider=model_config.provider,
model_name=model_config.name,
model_mode=model_config.mode.value,
mode=mode,
instruction=instruction,
ideal_output=ideal_output,
tool_catalogue_text=tool_catalogue_text,
installed_tools=installed_tools,
current_graph=current_graph,
)
+16 -18
View File
@@ -12,7 +12,7 @@ from datetime import UTC, datetime
from typing import Any
from celery import shared_task
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
@@ -35,7 +35,7 @@ from models.enums import (
WorkflowRunTriggeredFrom,
WorkflowTriggerStatus,
)
from models.model import App, EndUser
from models.model import EndUser
from models.provider_ids import TriggerProviderID
from models.trigger import TriggerSubscription, WorkflowPluginTrigger, WorkflowTriggerLog
from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun
@@ -99,26 +99,24 @@ def dispatch_trigger_debug_event(
return 0
def _get_published_workflows_by_app_ids(
def _get_latest_workflows_by_app_ids(
session: Session, subscribers: Sequence[WorkflowPluginTrigger]
) -> Mapping[str, Workflow]:
"""Get current published workflows through apps.workflow_id."""
app_ids = {trigger.app_id for trigger in subscribers}
tenant_ids = {trigger.tenant_id for trigger in subscribers}
if not app_ids or not tenant_ids:
return {}
workflows = session.scalars(
select(Workflow)
.join(App, App.workflow_id == Workflow.id)
"""Get the latest workflows by app_ids"""
workflow_query = (
select(Workflow.app_id, func.max(Workflow.created_at).label("max_created_at"))
.where(
App.id.in_(app_ids),
App.tenant_id.in_(tenant_ids),
App.workflow_id.isnot(None),
Workflow.app_id == App.id,
Workflow.tenant_id == App.tenant_id,
Workflow.app_id.in_({t.app_id for t in subscribers}),
Workflow.version != Workflow.VERSION_DRAFT,
)
.group_by(Workflow.app_id)
.subquery()
)
workflows = session.scalars(
select(Workflow).join(
workflow_query,
(Workflow.app_id == workflow_query.c.app_id) & (Workflow.created_at == workflow_query.c.max_created_at),
)
).all()
return {w.app_id: w for w in workflows}
@@ -264,7 +262,7 @@ def dispatch_triggered_workflow(
# Ensure expire_on_commit is set to False to remain workflows available
with session_factory.create_session() as session:
workflows: Mapping[str, Workflow] = _get_published_workflows_by_app_ids(session, subscribers)
workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers)
end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch(
type=EndUserType.TRIGGER,
@@ -127,9 +127,6 @@ class TestWebhookService:
db_session_with_containers.add(workflow)
db_session_with_containers.flush()
app.workflow_id = workflow.id
db_session_with_containers.flush()
# Create webhook trigger
webhook_id = fake.uuid4()[:16]
webhook_trigger = WorkflowWebhookTrigger(
@@ -2,7 +2,6 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
@@ -241,40 +240,6 @@ class TestWebhookServiceLookupWithContainers:
with pytest.raises(ValueError, match="Workflow not found"):
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
def test_get_webhook_trigger_and_workflow_uses_app_workflow_id(
self, db_session_with_containers: Session, flask_app_with_containers: Flask
):
del flask_app_with_containers
factory = WebhookServiceRelationshipFactory
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
app = factory.create_app(db_session_with_containers, tenant, account)
current_workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
)
newer_workflow = factory.create_workflow(
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-15.001"
)
current_workflow.created_at = datetime(2026, 4, 14)
newer_workflow.created_at = datetime(2026, 4, 15)
app.workflow_id = current_workflow.id
db_session_with_containers.commit()
webhook_trigger = factory.create_webhook_trigger(
db_session_with_containers, app=app, account=account, node_id="node-1"
)
factory.create_app_trigger(
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED
)
got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow(
webhook_trigger.webhook_id
)
assert got_trigger.id == webhook_trigger.id
assert got_workflow.id == current_workflow.id
assert got_workflow.id != newer_workflow.id
assert got_node_config["id"] == "node-1"
def test_get_webhook_trigger_and_workflow_returns_debug_draft_workflow(
self, db_session_with_containers: Session, flask_app_with_containers: Flask
):
@@ -1,6 +1,5 @@
from __future__ import annotations
import json
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -453,214 +452,3 @@ def test_workflow_generate_current_graph_defaults_to_none(app: Flask, monkeypatc
method(api, "t1")
assert captured["current_graph"] is None
def test_workflow_generate_accepts_auto_mode(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 3: the payload Literal must accept ``auto``; the controller forwards
it unchanged (the service resolves it) and returns the resolved ``mode``."""
api = generator_module.WorkflowGenerateApi()
method = unwrap(api.post)
captured: dict = {}
def _capture(**kwargs):
captured.update(kwargs)
return {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
"mode": "advanced-chat",
}
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
payload = _workflow_generate_payload()
payload["mode"] = "auto"
with app.test_request_context("/console/api/workflow-generate", method="POST", json=payload):
response = method(api, "t1")
assert captured["mode"] == "auto"
assert response["mode"] == "advanced-chat"
# ─ /workflow-generate/suggestions ─────────────────────────────────────────────
def test_generate_instruction_suggestions_parses_and_cleans(monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (i): a mocked default model returning a JSON array is parsed + cleaned."""
from core.llm_generator import llm_generator as llm_gen_module
instance = MagicMock()
instance.invoke_llm.return_value.message.get_text_content.return_value = '["Summarize a URL", "Translate text"]'
mock_manager = MagicMock()
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
tenant_id="t1", mode="workflow", count=4
)
assert result == ["Summarize a URL", "Translate text"]
def test_generate_instruction_suggestions_dedupes_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
"""Whitespace / surrounding quotes are stripped, case-insensitive dupes dropped, capped to count."""
from core.llm_generator import llm_generator as llm_gen_module
instance = MagicMock()
instance.invoke_llm.return_value.message.get_text_content.return_value = (
'[" Summarize a URL ", "summarize a URL", "\'Translate text\'", "Draft an email", "Extra idea"]'
)
mock_manager = MagicMock()
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
tenant_id="t1", mode="advanced-chat", count=3
)
assert result == ["Summarize a URL", "Translate text", "Draft an email"]
def test_generate_instruction_suggestions_no_default_model_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (ii): a missing default model degrades to an empty list, never raising."""
from core.llm_generator import llm_generator as llm_gen_module
mock_manager = MagicMock()
mock_manager.for_tenant.return_value.get_default_model_instance.side_effect = ProviderTokenNotInitError(
"no default model"
)
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(tenant_id="t1", mode="workflow")
assert result == []
def test_workflow_instruction_suggestions_route_returns_list(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (iii): the route wraps the generator output in {"suggestions": [...]}."""
api = generator_module.WorkflowInstructionSuggestionsApi()
method = unwrap(api.post)
captured: dict = {}
def _suggest(**kwargs):
captured.update(kwargs)
return ["Summarize a URL", "Translate text"]
monkeypatch.setattr(generator_module.LLMGenerator, "generate_workflow_instruction_suggestions", _suggest)
with app.test_request_context(
"/console/api/workflow-generate/suggestions",
method="POST",
json={"mode": "workflow", "language": "French", "count": 3},
):
response = method(api, "t1")
assert response == {"suggestions": ["Summarize a URL", "Translate text"]}
assert captured["mode"] == "workflow"
assert captured["language"] == "French"
assert captured["count"] == 3
def test_workflow_instruction_suggestions_route_empty_is_valid_200(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (iii): an empty list is a valid soft-fail response."""
api = generator_module.WorkflowInstructionSuggestionsApi()
method = unwrap(api.post)
monkeypatch.setattr(
generator_module.LLMGenerator,
"generate_workflow_instruction_suggestions",
lambda **_kwargs: [],
)
with app.test_request_context(
"/console/api/workflow-generate/suggestions",
method="POST",
json={"mode": "advanced-chat"},
):
response = method(api, "t1")
assert response == {"suggestions": []}
# ─ /workflow-generate/stream ──────────────────────────────────────────────────
def _read_sse_frames(response) -> list[dict]:
"""Decode an SSE Response body into its parsed ``data:`` JSON frames."""
body = response.get_data(as_text=True)
frames = []
for chunk in body.strip().split("\n\n"):
chunk = chunk.strip()
if chunk.startswith("data: "):
frames.append(json.loads(chunk[len("data: ") :]))
return frames
def test_workflow_generate_stream_emits_plan_then_result(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 2c: the stream endpoint writes one SSE frame per service event."""
api = generator_module.WorkflowGenerateStreamApi()
method = unwrap(api.post)
def _stream(**_kwargs):
yield ("plan", {"title": "Summarizer", "mode": "workflow", "nodes": []})
yield ("result", {"graph": {"nodes": []}, "error": "", "mode": "workflow"})
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
with app.test_request_context(
"/console/api/workflow-generate/stream",
method="POST",
json=_workflow_generate_payload(),
):
response = method(api, "t1")
assert response.mimetype == "text/event-stream"
frames = _read_sse_frames(response)
assert [f["event"] for f in frames] == ["plan", "result"]
assert frames[0]["title"] == "Summarizer"
assert frames[1]["mode"] == "workflow"
def test_workflow_generate_stream_provider_error_emits_result_event(
app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Task 2c: a provider-init error becomes a single MODEL_ERROR result frame, not a non-SSE error."""
api = generator_module.WorkflowGenerateStreamApi()
method = unwrap(api.post)
def _stream(**_kwargs):
raise ProviderTokenNotInitError("missing token")
yield # pragma: no cover - marks this a generator
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
with app.test_request_context(
"/console/api/workflow-generate/stream",
method="POST",
json=_workflow_generate_payload(),
):
response = method(api, "t1")
frames = _read_sse_frames(response)
assert len(frames) == 1
assert frames[0]["event"] == "result"
assert frames[0]["errors"][0]["code"] == "MODEL_ERROR"
assert frames[0]["graph"]["nodes"] == []
def test_workflow_generate_stream_rejects_empty_instruction(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 2c: empty instructions get a normal 400 JSON BEFORE the stream opens."""
api = generator_module.WorkflowGenerateStreamApi()
method = unwrap(api.post)
payload = _workflow_generate_payload()
payload["instruction"] = " "
with app.test_request_context("/console/api/workflow-generate/stream", method="POST", json=payload):
response, status = method(api, "t1")
assert status == 400
assert response["errors"][0]["code"] == "EMPTY_INSTRUCTION"
@@ -361,117 +361,6 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra
assert exc.value.description == "invalid snippet workflow graph"
def test_update_published_snippet_workflow_returns_updated_workflow(
app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
workflow = SimpleNamespace(
id="workflow-1",
graph_dict={"nodes": [], "edges": []},
features_dict={},
unique_hash="hash-1",
version="2024-01-01 00:00:00",
marked_name="v1",
marked_comment="first version",
created_by_account=None,
created_at=datetime(2024, 1, 1),
updated_by_account=None,
updated_at=datetime(2024, 1, 1),
tool_published=False,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
user = _account("account-1")
input_fields = [{"variable": "query", "type": "text"}]
snippet = _snippet(input_fields=json.dumps(input_fields))
session = SimpleNamespace()
update_workflow = Mock(return_value=workflow)
class TransactionContext:
def __enter__(self):
return session
def __exit__(self, exc_type, exc, tb):
return False
class SessionMaker:
def begin(self):
return TransactionContext()
monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker()))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(update_workflow=update_workflow),
)
api = snippet_workflow_module.SnippetWorkflowByIdApi()
handler = unwrap(api.patch)
with app.test_request_context(
"/snippets/snippet-1/workflows/workflow-1",
method="PATCH",
json={"marked_name": "v1", "marked_comment": "first version"},
):
response = handler(api, user, snippet, workflow_id="workflow-1")
update_workflow.assert_called_once_with(
session=session,
snippet=snippet,
workflow_id="workflow-1",
account=user,
data={"marked_name": "v1", "marked_comment": "first version"},
)
assert response["marked_name"] == "v1"
assert response["marked_comment"] == "first version"
assert response["input_fields"] == input_fields
def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask) -> None:
api = snippet_workflow_module.SnippetWorkflowByIdApi()
handler = unwrap(api.patch)
with app.test_request_context("/snippets/snippet-1/workflows/workflow-1", method="PATCH", json={}):
response, status_code = handler(api, _account("account-1"), _snippet(), workflow_id="workflow-1")
assert status_code == 400
assert response == {"message": "No valid fields to update"}
def test_update_published_snippet_workflow_raises_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
user = _account("account-1")
snippet = _snippet()
class TransactionContext:
def __enter__(self):
return SimpleNamespace()
def __exit__(self, exc_type, exc, tb):
return False
class SessionMaker:
def begin(self):
return TransactionContext()
monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker()))
monkeypatch.setattr(
snippet_workflow_module,
"SnippetService",
lambda: SimpleNamespace(update_workflow=Mock(return_value=None)),
)
api = snippet_workflow_module.SnippetWorkflowByIdApi()
handler = unwrap(api.patch)
with app.test_request_context(
"/snippets/snippet-1/workflows/missing-workflow",
method="PATCH",
json={"marked_name": "v1"},
):
with pytest.raises(NotFound, match="Workflow not found"):
handler(api, user, snippet, workflow_id="missing-workflow")
def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
snippet = _snippet()
monkeypatch.setattr(
@@ -155,36 +155,6 @@ class TestTagListApi:
assert result["name"] == "test-tag"
assert result["binding_count"] == "0"
def test_post_snippet_tag_checks_snippet_rbac_when_enabled(self, app: Flask, admin_user, tag, payload_patch):
api = TagListApi()
method = unwrap(api.post)
payload = {"name": "snippet-tag", "type": "snippet"}
with app.test_request_context("/", json=payload):
with (
payload_patch(payload),
patch("controllers.console.tag.tags.dify_config.RBAC_ENABLED", True),
patch(
"controllers.console.tag.tags.current_account_with_tenant",
return_value=(SimpleNamespace(id="user-1"), "tenant-1"),
),
patch("controllers.console.tag.tags.enforce_rbac_access") as enforce_mock,
patch(
"controllers.console.tag.tags.TagService.save_tags",
return_value=tag,
),
):
method(api, admin_user)
enforce_mock.assert_called_once_with(
tenant_id="tenant-1",
account_id="user-1",
resource_type=module.RBACResourceScope.WORKSPACE,
scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
def test_post_forbidden(self, app: Flask, readonly_user, payload_patch):
api = TagListApi()
method = unwrap(api.post)
@@ -3,7 +3,7 @@ Unit tests for Service API wraps (authentication decorators)
"""
import uuid
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from flask import Flask
@@ -469,10 +469,7 @@ class TestCloudEditionBillingRateLimitCheck:
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit")
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.wraps.sessionmaker")
def test_rejects_over_rate_limit(
self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask
):
def test_rejects_over_rate_limit(self, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask):
"""Test that Forbidden is raised when over rate limit."""
# Arrange
mock_validate_token.return_value = Mock(tenant_id="tenant123")
@@ -482,10 +479,6 @@ class TestCloudEditionBillingRateLimitCheck:
mock_rate_limit.limit = 10
mock_rate_limit.subscription_plan = "pro"
mock_get_rate_limit.return_value = mock_rate_limit
rate_limit_log_session = MagicMock()
session_factory = MagicMock()
session_factory.begin.return_value.__enter__.return_value = rate_limit_log_session
mock_sessionmaker.return_value = session_factory
with patch("controllers.service_api.wraps.redis_client") as mock_redis:
mock_redis.zcard.return_value = 15 # Over limit
@@ -499,9 +492,6 @@ class TestCloudEditionBillingRateLimitCheck:
with pytest.raises(Forbidden) as exc_info:
knowledge_request()
assert "rate limit" in str(exc_info.value)
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
rate_limit_log_session.add.assert_called_once()
mock_db.session.commit.assert_not_called()
class TestValidateDatasetToken:
@@ -683,75 +683,3 @@ class TestLLMGenerator:
"tenant_id", "flow_id", "current", "instruction", model_config_entity, "ideal"
)
assert "An unexpected error occurred" in result["error"]
@patch("core.llm_generator.llm_generator.ModelManager")
def test_generate_workflow_instruction_suggestions_success(self, mock_model_manager):
mock_model_instance = MagicMock()
mock_model_manager.for_tenant.return_value.get_default_model_instance.return_value = mock_model_instance
mock_response = MagicMock(spec=LLMResult)
mock_response.message.get_text_content.return_value = '["Idea 1", "Idea 2", "Idea 3", "Idea 4"]'
mock_model_instance.invoke_llm.return_value = mock_response
with patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context", return_value="context"):
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="workflow", language="en", count=4)
assert result == ["Idea 1", "Idea 2", "Idea 3", "Idea 4"]
@patch("core.llm_generator.llm_generator.ModelManager")
def test_generate_workflow_instruction_suggestions_no_model(self, mock_model_manager):
mock_model_manager.for_tenant.return_value.get_default_model_instance.side_effect = Exception("No default model")
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="workflow")
assert result == []
@patch("core.llm_generator.llm_generator.ModelManager")
def test_generate_workflow_instruction_suggestions_invoke_error(self, mock_model_manager):
mock_model_instance = MagicMock()
mock_model_manager.for_tenant.return_value.get_default_model_instance.return_value = mock_model_instance
mock_model_instance.invoke_llm.side_effect = Exception("Invoke error")
with patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context", return_value="context"):
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="workflow")
assert result == []
@patch("core.llm_generator.llm_generator.ModelManager")
def test_generate_workflow_instruction_suggestions_with_chatflow(self, mock_model_manager):
mock_model_instance = MagicMock()
mock_model_manager.for_tenant.return_value.get_default_model_instance.return_value = mock_model_instance
mock_response = MagicMock(spec=LLMResult)
mock_response.message.get_text_content.return_value = '["Idea 1", "Idea 2"]'
mock_model_instance.invoke_llm.return_value = mock_response
with patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context", return_value="context"):
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="advanced-chat", count=2)
assert result == ["Idea 1", "Idea 2"]
@patch("core.llm_generator.llm_generator.db")
@patch("core.llm_generator.llm_generator.build_tool_catalogue")
@patch("core.llm_generator.llm_generator.format_tool_catalogue")
def test_build_suggestion_context_success(self, mock_format, mock_build, mock_db):
mock_db.session.scalars.return_value.all.return_value = ["kb1", "kb2"]
mock_format.return_value = "tool1\ntool2"
result = LLMGenerator._build_suggestion_context("tenant_id")
assert "Knowledge bases:" in result
assert "- kb1" in result
assert "- kb2" in result
assert "Installed tools:" in result
assert "tool1" in result
@patch("core.llm_generator.llm_generator.db")
@patch("core.llm_generator.llm_generator.build_tool_catalogue")
def test_build_suggestion_context_errors(self, mock_build, mock_db):
mock_db.session.scalars.side_effect = Exception("DB Error")
mock_build.side_effect = Exception("Tool Error")
result = LLMGenerator._build_suggestion_context("tenant_id")
assert result == ""
@@ -43,57 +43,6 @@ class TestMCPClient:
assert client.timeout is None
assert client.sse_read_timeout is None
def test_init_with_dynamic_request_headers(self):
"""Test client initialization with dynamic request headers."""
from flask import Flask
app = Flask("test")
with app.test_request_context(headers={"X-Custom-Auth": "my-secret-token", "X-User-Id": "user123"}):
client = MCPClient(
server_url="http://test.example.com",
headers={
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
"X-Request-User": "{{request.header.X-User-Id}}",
"X-Static-Header": "static-val",
},
)
assert client.headers == {
"Authorization": "Bearer my-secret-token",
"X-Request-User": "user123",
"X-Static-Header": "static-val",
}
def test_init_with_dynamic_request_headers_missing(self):
"""Test client initialization with dynamic request headers that are missing."""
from flask import Flask
app = Flask("test")
with app.test_request_context(headers={}):
client = MCPClient(
server_url="http://test.example.com",
headers={
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
},
)
assert client.headers == {
"Authorization": "Bearer ",
}
def test_init_with_dynamic_request_headers_no_context(self):
"""Test client initialization with dynamic request headers but no request context."""
client = MCPClient(
server_url="http://test.example.com",
headers={
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
},
)
assert client.headers == {
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
}
@patch("core.mcp.mcp_client.streamablehttp_client")
@patch("core.mcp.mcp_client.ClientSession")
def test_initialize_with_mcp_url(self, mock_client_session, mock_streamable_client):
@@ -3888,17 +3888,7 @@ class TestDatasetRetrievalAdditionalHelpers:
trace_manager.add_trace_task.assert_not_called()
def test_on_query(self, retrieval: DatasetRetrieval) -> None:
db_mock = Mock()
audit_session = MagicMock()
session_factory = MagicMock()
session_factory.begin.return_value.__enter__.return_value = audit_session
with (
patch("core.rag.retrieval.dataset_retrieval.db", db_mock),
patch(
"core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=session_factory
) as sessionmaker_mock,
):
with patch("core.rag.retrieval.dataset_retrieval.db.session") as mock_session:
retrieval._on_query(
query=None,
attachment_ids=None,
@@ -3907,7 +3897,7 @@ class TestDatasetRetrievalAdditionalHelpers:
user_from="account",
user_id="u1",
)
audit_session.add_all.assert_not_called()
mock_session.add_all.assert_not_called()
retrieval._on_query(
query="python",
@@ -3917,22 +3907,11 @@ class TestDatasetRetrievalAdditionalHelpers:
user_from="account",
user_id="u1",
)
sessionmaker_mock.assert_called_once_with(bind=db_mock.engine, expire_on_commit=False)
audit_session.add_all.assert_called_once()
added_queries = audit_session.add_all.call_args.args[0]
assert len(added_queries) == 2
db_mock.session.commit.assert_not_called()
mock_session.add_all.assert_called()
mock_session.commit.assert_called()
def test_on_query_normalizes_workflow_end_user_role(self, retrieval: DatasetRetrieval) -> None:
db_mock = Mock()
audit_session = MagicMock()
session_factory = MagicMock()
session_factory.begin.return_value.__enter__.return_value = audit_session
with (
patch("core.rag.retrieval.dataset_retrieval.db", db_mock),
patch("core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=session_factory),
):
with patch("core.rag.retrieval.dataset_retrieval.db.session") as mock_session:
retrieval._on_query(
query="python",
attachment_ids=None,
@@ -3942,11 +3921,12 @@ class TestDatasetRetrievalAdditionalHelpers:
user_id="u1",
)
audit_session.add_all.assert_called_once()
added_queries = audit_session.add_all.call_args.args[0]
mock_session.add_all.assert_called_once()
added_queries = mock_session.add_all.call_args.args[0]
assert len(added_queries) == 1
assert added_queries[0].created_by_role == CreatorUserRole.END_USER
mock_session.commit.assert_called_once()
def test_handle_invoke_result(self, retrieval: DatasetRetrieval) -> None:
usage = LLMUsage.empty_usage()
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Generator
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
@@ -131,25 +131,18 @@ def test_create_message_files_and_invoke_generator():
created.append(obj)
return obj
file_session = MagicMock()
session_factory = MagicMock()
session_factory.begin.return_value.__enter__.return_value = file_session
with (
patch("core.tools.tool_engine.MessageFile", side_effect=_message_file_factory),
patch("core.tools.tool_engine.db") as mock_db,
patch("core.tools.tool_engine.sessionmaker", return_value=session_factory) as mock_sessionmaker,
):
ids = ToolEngine._create_message_files(
tool_messages=binaries,
agent_message=SimpleNamespace(id="msg-1"),
invoke_from=InvokeFrom.DEBUGGER,
user_id="user-1",
)
with patch("core.tools.tool_engine.MessageFile", side_effect=_message_file_factory):
with patch("core.tools.tool_engine.db") as mock_db:
ids = ToolEngine._create_message_files(
tool_messages=binaries,
agent_message=SimpleNamespace(id="msg-1"),
invoke_from=InvokeFrom.DEBUGGER,
user_id="user-1",
)
assert ids == ["mf-1", "mf-2"]
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
assert file_session.add.call_count == 2
mock_db.session.close.assert_not_called()
assert mock_db.session.add.call_count == 2
mock_db.session.close.assert_called_once()
tool = _build_tool()
invoked = list(ToolEngine._invoke(tool, {"a": 1}, user_id="u"))
@@ -2921,168 +2921,3 @@ class TestWorkflowGeneratorDuplicateNodeIds:
codes = {e["code"] for e in result["errors"]}
assert "DUPLICATE_NODE_ID" in codes
def _stream_planner_json() -> str:
return json.dumps(
{
"title": "URL Summarizer",
"description": "Fetch a URL, summarize it, return the summary.",
"app_name": "Summarizer",
"icon": "🔗",
"nodes": [
{"label": "Start", "node_type": "start", "purpose": "User submits URL."},
{"label": "Summarize", "node_type": "llm", "purpose": "Summarize the page."},
{"label": "End", "node_type": "end", "purpose": "Return summary."},
],
}
)
def _stream_builder_json() -> str:
return json.dumps(
{
"nodes": [
{
"id": "node1",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {"type": "start", "title": "Start", "desc": "", "variables": []},
},
{
"id": "node2",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "llm",
"title": "Summarize",
"desc": "",
"prompt_template": [{"role": "user", "text": "{{#node1.url#}}"}],
},
},
{
"id": "node3",
"type": "custom",
"position": {"x": 0, "y": 0},
"data": {
"type": "end",
"title": "End",
"desc": "",
"outputs": [{"variable": "summary", "value_selector": ["node2", "text"]}],
},
},
],
"edges": [
{"id": "x", "source": "node1", "target": "node2", "type": "custom"},
{"id": "y", "source": "node2", "target": "node3", "type": "custom"},
],
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
}
)
class TestWorkflowGeneratorStream:
"""``generate_workflow_graph_stream`` yields a ``plan`` event then a ``result`` event."""
def test_stream_emits_plan_then_result(self):
model_instance = MagicMock()
model_instance.invoke_llm.side_effect = [
_llm_result(_stream_planner_json()),
_llm_result(_stream_builder_json()),
]
events = list(
WorkflowGenerator.generate_workflow_graph_stream(
model_instance=model_instance,
model_parameters={},
provider="openai",
model_name="gpt-4o",
model_mode="chat",
mode="workflow",
instruction="Summarize a URL",
)
)
assert [name for name, _ in events] == ["plan", "result"]
plan = events[0][1]
assert plan["title"] == "URL Summarizer"
assert plan["app_name"] == "Summarizer"
assert plan["mode"] == "workflow"
assert [n["node_type"] for n in plan["nodes"]] == ["start", "llm", "end"]
assert plan["nodes"][0]["label"] == "Start"
assert plan["nodes"][0]["purpose"] == "User submits URL."
result = events[1][1]
assert result["error"] == ""
assert result["mode"] == "workflow"
assert [n["data"]["type"] for n in result["graph"]["nodes"]] == ["start", "llm", "end"]
def test_stream_planner_failure_emits_only_result(self):
model_instance = MagicMock()
model_instance.invoke_llm.side_effect = RuntimeError("planner exploded")
events = list(
WorkflowGenerator.generate_workflow_graph_stream(
model_instance=model_instance,
model_parameters={},
provider="openai",
model_name="gpt-4o",
model_mode="chat",
mode="workflow",
instruction="x",
)
)
assert [name for name, _ in events] == ["result"]
result = events[0][1]
assert "planner exploded" in result["error"]
assert result["graph"]["nodes"] == []
assert result["mode"] == "workflow"
def test_stream_and_blocking_results_match(self):
"""The streaming ``result`` event must equal the blocking return value."""
stream_instance = MagicMock()
stream_instance.invoke_llm.side_effect = [
_llm_result(_stream_planner_json()),
_llm_result(_stream_builder_json()),
]
blocking_instance = MagicMock()
blocking_instance.invoke_llm.side_effect = [
_llm_result(_stream_planner_json()),
_llm_result(_stream_builder_json()),
]
kwargs = {
"model_parameters": {},
"provider": "openai",
"model_name": "gpt-4o",
"model_mode": "chat",
"mode": "advanced-chat",
"instruction": "Greet me",
}
stream_events = list(WorkflowGenerator.generate_workflow_graph_stream(model_instance=stream_instance, **kwargs))
stream_result = next(payload for name, payload in stream_events if name == "result")
blocking_result = WorkflowGenerator.generate_workflow_graph(model_instance=blocking_instance, **kwargs)
assert stream_result == blocking_result
def test_blocking_result_includes_resolved_mode(self):
"""Task 3: the non-streaming envelope carries the resolved ``mode`` too."""
model_instance = MagicMock()
model_instance.invoke_llm.side_effect = [
_llm_result(_stream_planner_json()),
_llm_result(_stream_builder_json()),
]
result = WorkflowGenerator.generate_workflow_graph(
model_instance=model_instance,
model_parameters={},
provider="openai",
model_name="gpt-4o",
model_mode="chat",
mode="workflow",
instruction="Summarize a URL",
)
assert result["mode"] == "workflow"
@@ -1,111 +0,0 @@
"""Tests for the uuidv7 SQL migration's PostgreSQL 18 compatibility guard.
The migration file name is not a valid Python identifier (it starts with a date and
contains hyphens), so it is loaded directly from its path. The ``models`` import at the
top of the migration is stubbed because the migration never uses it during
``upgrade()``/``downgrade()`` and pulling in the real package would require a full app
context.
"""
import importlib.util
import sys
import types
from pathlib import Path
from unittest import mock
import pytest
MIGRATION_PATH = (
Path(__file__).resolve().parents[3]
/ "migrations"
/ "versions"
/ "2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py"
)
def _load_migration():
# The migration does `import models as models` but never references it, so a stub is
# enough and keeps the test free of any database/app configuration.
sys.modules.setdefault("models", types.ModuleType("models"))
spec = importlib.util.spec_from_file_location("uuidv7_pg18_migration_under_test", MIGRATION_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _make_bind(dialect_name):
bind = mock.MagicMock()
bind.dialect.name = dialect_name
return bind
def _executed_sql(fake_op):
return [str(call.args[0]) for call in fake_op.execute.call_args_list]
@pytest.fixture
def migration():
return _load_migration()
def test_upgrade_creates_both_functions_when_native_uuidv7_absent(migration):
# PostgreSQL 13 to 17: no native pg_catalog.uuidv7(), so both functions are created.
# The DO block contains the CREATE FUNCTION guarded by IF NOT EXISTS, and
# uuidv7_boundary is created unconditionally.
bind = _make_bind("postgresql")
with mock.patch.object(migration, "op") as fake_op:
fake_op.get_bind.return_value = bind
migration.upgrade()
sql = _executed_sql(fake_op)
assert any("CREATE FUNCTION public.uuidv7()" in stmt for stmt in sql)
assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql)
def test_upgrade_skips_uuidv7_but_keeps_boundary_when_native_present(migration):
# PostgreSQL 18: native pg_catalog.uuidv7() exists, so the DO block must guard
# the CREATE FUNCTION with an IF NOT EXISTS check against pg_catalog.
# uuidv7_boundary is still missing and has to be created unconditionally.
bind = _make_bind("postgresql")
with mock.patch.object(migration, "op") as fake_op:
fake_op.get_bind.return_value = bind
migration.upgrade()
sql = _executed_sql(fake_op)
# The DO block must contain the pg_catalog existence check.
do_block = next((stmt for stmt in sql if "DO $do$" in stmt), None)
assert do_block is not None
assert "pg_catalog" in do_block
assert "uuidv7" in do_block
assert "IF NOT EXISTS" in do_block
# uuidv7_boundary is always created (not guarded by the DO block).
assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql)
def test_upgrade_is_noop_on_non_postgres(migration):
bind = _make_bind("sqlite")
with mock.patch.object(migration, "op") as fake_op:
fake_op.get_bind.return_value = bind
migration.upgrade()
fake_op.execute.assert_not_called()
def test_downgrade_uses_if_exists_and_public_schema(migration):
bind = _make_bind("postgresql")
with mock.patch.object(migration, "op") as fake_op:
fake_op.get_bind.return_value = bind
migration.downgrade()
sql = _executed_sql(fake_op)
assert "DROP FUNCTION IF EXISTS public.uuidv7()" in sql
assert "DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)" in sql
def test_downgrade_is_noop_on_non_postgres(migration):
bind = _make_bind("sqlite")
with mock.patch.object(migration, "op") as fake_op:
fake_op.get_bind.return_value = bind
migration.downgrade()
fake_op.execute.assert_not_called()
@@ -437,10 +437,12 @@ def test_save_agent_app_composer_rejects_version_save_strategy():
def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch):
agent = SimpleNamespace(
id="agent-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="version-1",
active_config_is_published=True,
updated_by=None,
)
active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"))
fake_session = FakeSession(scalar=[agent])
saved = {}
@@ -451,6 +453,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
"_save_agent_draft",
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True})
payload = ComposerSavePayload.model_validate(
{
@@ -473,6 +476,43 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
assert fake_session.commits == 1
def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
agent_soul = _agent_soul_with_model()
agent = SimpleNamespace(
id="agent-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="version-1",
active_config_is_published=False,
updated_by=None,
)
active_version = SimpleNamespace(config_snapshot_dict=agent_soul.model_dump(mode="json"))
fake_session = FakeSession(scalar=[agent], scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
monkeypatch.setattr(
AgentComposerService,
"_save_agent_draft",
lambda **_kwargs: SimpleNamespace(id="draft-1"),
)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
payload = ComposerSavePayload.model_validate(
{
"variant": ComposerVariant.AGENT_APP.value,
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
"agent_soul": agent_soul.model_dump(mode="json"),
}
)
AgentComposerService.save_agent_app_composer(
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert agent.active_config_is_published is True
assert fake_session.commits == 1
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
agent = Agent(
id="agent-1",
@@ -565,7 +605,11 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict
assert fake_session.commits == 1
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft])
active_version = SimpleNamespace(config_snapshot_dict=build_draft.config_snapshot_dict)
fake_session = FakeSession(
scalar=[agent, build_draft, normal_draft, active_version],
scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]],
)
monkeypatch.setattr(composer_service.db, "session", fake_session)
applied = AgentComposerService.apply_agent_app_build_draft(
@@ -576,6 +620,62 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
assert applied["result"] == "success"
assert applied["draft"]["id"] == normal_draft.id
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
assert agent.active_config_is_published is True
assert fake_session.deleted == [build_draft]
assert fake_session.commits == 1
def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs(monkeypatch: pytest.MonkeyPatch):
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Iris",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-1",
active_config_is_published=True,
)
active_agent_soul = _agent_soul_with_model()
build_agent_soul = AgentSoulConfig.model_validate(
{
**active_agent_soul.model_dump(mode="json"),
"prompt": {
"system_prompt": "Build draft prompt",
},
}
)
build_draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
account_id="account-1",
draft_owner_key="account-1",
base_snapshot_id="version-1",
config_snapshot=build_agent_soul,
)
normal_draft = AgentConfigDraft(
tenant_id="tenant-1",
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
base_snapshot_id="version-1",
config_snapshot=active_agent_soul,
)
active_version = SimpleNamespace(config_snapshot_dict=active_agent_soul.model_dump(mode="json"))
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft, active_version])
monkeypatch.setattr(composer_service.db, "session", fake_session)
AgentComposerService.apply_agent_app_build_draft(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
)
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
assert agent.active_config_is_published is False
assert fake_session.deleted == [build_draft]
@@ -1611,6 +1711,52 @@ def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypa
)
def test_agent_app_draft_match_does_not_mark_create_version_as_published(monkeypatch: pytest.MonkeyPatch):
agent_soul = AgentSoulConfig()
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="snapshot-1",
)
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.CREATE_VERSION]])
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
assert (
AgentComposerService._agent_soul_matches_active_config(
tenant_id="tenant-1",
agent=agent,
agent_soul=agent_soul,
)
is False
)
def test_agent_app_draft_match_marks_publish_visible_revision_as_published(monkeypatch: pytest.MonkeyPatch):
agent_soul = AgentSoulConfig()
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
source=AgentSource.AGENT_APP,
active_config_snapshot_id="snapshot-1",
)
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
monkeypatch.setattr(composer_service.db, "session", fake_session)
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
assert (
AgentComposerService._agent_soul_matches_active_config(
tenant_id="tenant-1",
agent=agent,
agent_soul=agent_soul,
)
is True
)
def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession(
scalar=[
@@ -633,8 +633,6 @@ class TestMyPermissions:
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
@pytest.mark.parametrize(
("role", "expected_snippet_keys"),
@@ -273,45 +273,6 @@ def test_sync_draft_workflow_updates_existing_draft_and_clears_variables(monkeyp
session.commit.assert_called_once()
def test_update_workflow_updates_marked_fields() -> None:
service = SnippetService.__new__(SnippetService)
workflow = SimpleNamespace(marked_name="", marked_comment="", updated_by=None, updated_at=None)
session = SimpleNamespace(scalar=Mock(return_value=workflow), add=Mock())
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
account = SimpleNamespace(id="account-1")
result = service.update_workflow(
session=session,
snippet=snippet,
workflow_id="workflow-1",
account=account,
data={"marked_name": "v1", "marked_comment": "first version", "ignored": "value"},
)
assert result is workflow
assert workflow.marked_name == "v1"
assert workflow.marked_comment == "first version"
assert workflow.updated_by == "account-1"
session.scalar.assert_called_once()
session.add.assert_called_once_with(workflow)
def test_update_workflow_returns_none_when_missing() -> None:
service = SnippetService.__new__(SnippetService)
session = SimpleNamespace(scalar=Mock(return_value=None), add=Mock())
result = service.update_workflow(
session=session,
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
workflow_id="missing-workflow",
account=SimpleNamespace(id="account-1"),
data={"marked_name": "v1"},
)
assert result is None
session.add.assert_not_called()
def test_get_default_block_configs_skips_empty_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
node_with_default = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"}))
node_without_default = SimpleNamespace(get_default_config=Mock(return_value=None))
@@ -639,8 +639,8 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up
assert len(bindings) == 1
assert bindings[0]["attachment_id"] == "file-1"
vector_instance.create_multimodal.assert_called_once()
documents = vector_instance.create_multimodal.call_args.args[0]
vector_instance.add_texts.assert_called_once()
documents = vector_instance.add_texts.call_args.args[0]
assert len(documents) == 1
assert documents[0].page_content == "img.png"
assert documents[0].metadata["doc_id"] == "file-1"
@@ -199,111 +199,3 @@ class TestWorkflowGeneratorService:
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
assert call_kwargs["current_graph"] is None
@patch("services.workflow_generator_service.LLMGenerator")
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
@patch("services.workflow_generator_service.format_tool_catalogue")
def test_auto_mode_resolves_via_classifier(
self,
mock_format_catalogue: MagicMock,
mock_build_catalogue: MagicMock,
mock_model_manager: MagicMock,
mock_workflow_generator: MagicMock,
mock_llm_generator: MagicMock,
):
"""Task 3: ``mode="auto"`` is classified before planning; the concrete mode reaches the runner."""
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
mock_build_catalogue.return_value = []
mock_format_catalogue.return_value = ""
mock_llm_generator.classify_workflow_mode.return_value = "workflow"
mock_workflow_generator.generate_workflow_graph.return_value = {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
WorkflowGeneratorService.generate_workflow_graph(
tenant_id="t-1",
mode="auto",
instruction="Summarize a URL",
model_config=_model_config(),
)
mock_llm_generator.classify_workflow_mode.assert_called_once()
classify_kwargs = mock_llm_generator.classify_workflow_mode.call_args.kwargs
assert classify_kwargs["tenant_id"] == "t-1"
assert classify_kwargs["instruction"] == "Summarize a URL"
assert mock_workflow_generator.generate_workflow_graph.call_args.kwargs["mode"] == "workflow"
@patch("services.workflow_generator_service.LLMGenerator")
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
@patch("services.workflow_generator_service.format_tool_catalogue")
def test_explicit_mode_skips_classifier(
self,
mock_format_catalogue: MagicMock,
mock_build_catalogue: MagicMock,
mock_model_manager: MagicMock,
mock_workflow_generator: MagicMock,
mock_llm_generator: MagicMock,
):
"""A concrete mode passes through unchanged without an extra classification call."""
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
mock_build_catalogue.return_value = []
mock_format_catalogue.return_value = ""
mock_workflow_generator.generate_workflow_graph.return_value = {
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
"message": "",
"error": "",
}
WorkflowGeneratorService.generate_workflow_graph(
tenant_id="t-1",
mode="advanced-chat",
instruction="A chat bot",
model_config=_model_config(),
)
mock_llm_generator.classify_workflow_mode.assert_not_called()
assert mock_workflow_generator.generate_workflow_graph.call_args.kwargs["mode"] == "advanced-chat"
@patch("services.workflow_generator_service.WorkflowGenerator")
@patch("services.workflow_generator_service.ModelManager")
@patch("services.workflow_generator_service.build_tool_catalogue")
@patch("services.workflow_generator_service.format_tool_catalogue")
def test_stream_delegates_to_runner_stream(
self,
mock_format_catalogue: MagicMock,
mock_build_catalogue: MagicMock,
mock_model_manager: MagicMock,
mock_workflow_generator: MagicMock,
):
"""Task 2b: the streaming facade resolves context and yields the runner's events through."""
instance = MagicMock(name="model_instance")
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = instance
mock_build_catalogue.return_value = []
mock_format_catalogue.return_value = ""
def _runner_stream(**_kwargs):
yield ("plan", {"mode": "workflow"})
yield ("result", {"error": "", "mode": "workflow"})
mock_workflow_generator.generate_workflow_graph_stream.side_effect = _runner_stream
events = list(
WorkflowGeneratorService.generate_workflow_graph_stream(
tenant_id="t-1",
mode="workflow",
instruction="Summarize a URL",
model_config=_model_config(),
)
)
assert [name for name, _ in events] == ["plan", "result"]
call_kwargs = mock_workflow_generator.generate_workflow_graph_stream.call_args.kwargs
assert call_kwargs["model_instance"] is instance
assert call_kwargs["mode"] == "workflow"
assert call_kwargs["provider"] == "openai"
@@ -98,7 +98,7 @@ class TestDispatchTriggeredWorkflow:
),
patch.object(
trigger_processing_tasks_module,
"_get_published_workflows_by_app_ids",
"_get_latest_workflows_by_app_ids",
) as get_workflows,
patch.object(
trigger_processing_tasks_module.EndUserService,
+5 -25
View File
@@ -14,31 +14,11 @@
"binName": "difyctl",
"checksumsSuffix": "-checksums.txt",
"targets": [
{
"id": "linux-x64",
"bunTarget": "bun-linux-x64",
"exe": false
},
{
"id": "linux-arm64",
"bunTarget": "bun-linux-arm64",
"exe": false
},
{
"id": "darwin-x64",
"bunTarget": "bun-darwin-x64",
"exe": false
},
{
"id": "darwin-arm64",
"bunTarget": "bun-darwin-arm64",
"exe": false
},
{
"id": "windows-x64",
"bunTarget": "bun-windows-x64",
"exe": true
}
{ "id": "linux-x64", "bunTarget": "bun-linux-x64", "exe": false },
{ "id": "linux-arm64", "bunTarget": "bun-linux-arm64", "exe": false },
{ "id": "darwin-x64", "bunTarget": "bun-darwin-x64", "exe": false },
{ "id": "darwin-arm64", "bunTarget": "bun-darwin-arm64", "exe": false },
{ "id": "windows-x64", "bunTarget": "bun-windows-x64", "exe": true }
]
}
},
@@ -1,41 +0,0 @@
"""Provider-agnostic shell provisioning/execution adapter for the Dify agent.
The boundary protocols live in ``protocols``; the default shellctl backend in
``shellctl``; and env-var-driven provider selection in ``config``/``factory``.
``create_shell_provisioner`` is the recommended entry point for callers.
"""
from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER, ShellAdapterSettings
from dify_agent.adapters.shell.factory import create_shell_provisioner
from dify_agent.adapters.shell.protocols import (
ShellEnvironmentDescriptor,
ShellExecutionResult,
ShellExecutorProtocol,
ShellFileTransferProtocol,
ShellHandle,
ShellProvisionProtocol,
)
from dify_agent.adapters.shell.shellctl import (
ShellctlEnvironmentDescriptor,
ShellctlProvisioner,
ShellFileTransferError,
ShellProvisionError,
create_default_shellctl_client_factory,
)
__all__ = [
"DEFAULT_SHELL_PROVIDER",
"ShellAdapterSettings",
"ShellEnvironmentDescriptor",
"ShellExecutionResult",
"ShellExecutorProtocol",
"ShellFileTransferError",
"ShellFileTransferProtocol",
"ShellHandle",
"ShellProvisionError",
"ShellProvisionProtocol",
"ShellctlEnvironmentDescriptor",
"ShellctlProvisioner",
"create_default_shellctl_client_factory",
"create_shell_provisioner",
]
@@ -1,29 +0,0 @@
from typing import ClassVar
from pydantic_settings import BaseSettings, SettingsConfigDict
DEFAULT_SHELL_PROVIDER = "shellctl"
class ShellAdapterSettings(BaseSettings):
"""Env-backed settings used to construct a shell provisioner.
``shellctl_auth_token`` defaults to ``None``; the factory forwards an empty
string to the shellctl client so it does not fall back to ambient process
credentials. Deployments that enable shellctl bearer auth must set
``DIFY_AGENT_SHELLCTL_AUTH_TOKEN`` explicitly.
"""
shell_provider: str = DEFAULT_SHELL_PROVIDER
shellctl_entrypoint: str | None = None
shellctl_auth_token: str | None = None
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
env_prefix="DIFY_AGENT_",
env_file=(".env", "dify-agent/.env"),
extra="ignore",
populate_by_name=True,
)
__all__ = ["DEFAULT_SHELL_PROVIDER", "ShellAdapterSettings"]
@@ -1,36 +0,0 @@
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
from dify_agent.adapters.shell.shellctl import (
ShellctlEnvironmentDescriptor,
ShellctlProvisioner,
create_default_shellctl_client_factory,
)
def create_shell_provisioner(
settings: ShellAdapterSettings | None = None,
) -> ShellProvisionProtocol[ShellctlEnvironmentDescriptor]:
"""Return the shell provisioner selected by ``DIFY_AGENT_SHELL_PROVIDER``.
Raises:
ValueError: if the provider name is unknown, or if the ``shellctl``
provider is selected without a non-empty ``DIFY_AGENT_SHELLCTL_ENTRYPOINT``.
"""
resolved = settings or ShellAdapterSettings()
provider = resolved.shell_provider.strip().lower()
match provider:
case "shellctl":
entrypoint = (resolved.shellctl_entrypoint or "").strip()
if not entrypoint:
raise ValueError("DIFY_AGENT_SHELLCTL_ENTRYPOINT is required for the 'shellctl' shell provider.")
return ShellctlProvisioner(
client_factory=create_default_shellctl_client_factory(
entrypoint=entrypoint,
token=resolved.shellctl_auth_token or "",
),
)
case _:
raise ValueError(f"Unknown shell provider: {resolved.shell_provider!r}.")
__all__ = ["create_shell_provisioner"]
@@ -1,128 +0,0 @@
from typing import Protocol, TypeVar
from pydantic import BaseModel, ConfigDict, model_validator
from typing_extensions import Self
class ShellEnvironmentDescriptor(BaseModel):
"""Minimal, serializable seed used to re-derive a provisioned environment.
Holds only the provider-agnostic identity needed to reattach to an existing
shell environment across a snapshot/resume cycle never live resources
(clients, handles, executors). Callers persist this in their snapshot and
pass it back to ``ShellProvisionProtocol.reattach`` to reconstruct an
equivalent ``ShellHandle`` without allocating a new environment.
Each provider defines its own concrete subclass carrying the fields it needs
to reattach (e.g. workspace path + session id for shellctl). Validation runs
at instantiation time via Pydantic so a malicious or corrupt snapshot cannot
escape the workspace root or inject shell syntax into lifecycle commands,
even if a future caller uses the provider directly.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
@model_validator(mode="after")
def _run_validate(self) -> Self:
self.validate()
return self
def validate(self) -> None:
"""validate the correctness of the object.
Advanced validations that requires remote procedure calls,
for example access control, quota checks, should be implemented in
provision and reattach.
"""
raise NotImplementedError
DescriptorT = TypeVar("DescriptorT", bound=ShellEnvironmentDescriptor)
class ShellExecutionResult(Protocol):
"""Completed shell command result.
``stdout``/``stderr``/``exit_code`` are reserved fields. Backends that
cannot distinguish a stream return an empty string for it, and return
``None`` from ``exit_code()`` when no exit status is available.
"""
def stdout(self) -> str: ...
def stderr(self) -> str: ...
def exit_code(self) -> int | None: ...
def truncated(self) -> bool: ...
class ShellExecutorProtocol(Protocol):
"""Runs commands inside an already-provisioned shell environment.
``execute`` drains the command to completion before returning there is no
separate ``wait`` step. This suits the current server-side callers (sandbox
file helpers, workspace bootstrap) that always run a script to completion.
If a future use case needs to start a command, interact with its stdin, or
interrupt it before completion, split this protocol into ``execute``
``ShellExecutionHandle`` plus ``wait`` / ``input`` / ``interrupt`` optional
capabilities, mirroring the shape that was prototyped here before
simplification.
"""
async def execute(
self,
command: str,
*,
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> ShellExecutionResult: ...
class ShellFileTransferProtocol(Protocol):
"""Moves file bytes between the caller and a provisioned shell environment.
``remote_path`` is interpreted by the backend relative to the provisioned
environment (for the shellctl backend, the session workspace). Higher-level
Dify/skill transfers are layered on top of this primitive, not implemented
here. Implementations raise on transfer failure (missing path, decode error,
or a non-zero transfer command).
"""
async def upload(self, *, content: bytes, remote_path: str) -> None: ...
async def download(self, *, remote_path: str) -> bytes: ...
# pyrefly: ignore [variance-mismatch]
# intended to be invariant
class ShellHandle(Protocol[DescriptorT]):
"""Live reference to one provisioned shell environment.
The handle itself is not serialized. ``descriptor()`` returns the minimal
seed needed to reconstruct an equivalent handle after a snapshot/resume.
"""
def descriptor(self) -> DescriptorT: ...
async def get_executor(self) -> ShellExecutorProtocol: ...
async def get_file_transfer(self) -> ShellFileTransferProtocol: ...
class ShellProvisionProtocol(Protocol[DescriptorT]):
"""Creates, reattaches to, and destroys shell environments.
``provision`` allocates a fresh environment; ``reattach`` rebuilds a live
handle for an environment that already exists (from a persisted descriptor)
without allocating a new one, so resumed runs can keep executing and
eventually clean up. ``destroy`` tears an environment down.
"""
async def provision(self) -> ShellHandle[DescriptorT]: ...
async def reattach(self, descriptor: DescriptorT) -> ShellHandle[DescriptorT]: ...
async def destroy(self, handle: ShellHandle[DescriptorT]) -> None: ...
@@ -1,486 +0,0 @@
from __future__ import annotations
import base64
import binascii
import logging
import re
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol
from dify_agent.adapters.shell.protocols import ShellEnvironmentDescriptor, ShellHandle
logger = logging.getLogger(__name__)
_WORKSPACE_ROOT = "~/workspace"
_DEFAULT_TIMEOUT_SECONDS = 30.0
_SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{7,16}")
# Drains at most this many shellctl output windows per wait so a stuck or
# pathologically chatty job cannot loop forever inside one wait() call.
_MAX_OUTPUT_WINDOWS = 64
_DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
_FILE_TRANSFER_TIMEOUT_SECONDS = 60.0
# Sentinels frame base64 download payloads so prompt/tmux noise around the
# shellctl merged output stream can be stripped before decoding.
_TRANSFER_BEGIN = "<<<DIFY_SHELL_FILE_BEGIN>>>"
_TRANSFER_END = "<<<DIFY_SHELL_FILE_END>>>"
_DOWNLOAD_MISSING_EXIT_CODE = 66
class ShellProvisionError(RuntimeError):
"""Raised when a shell environment cannot be provisioned."""
class ShellFileTransferError(RuntimeError):
"""Raised when a file cannot be uploaded to or downloaded from the workspace."""
class ShellctlEnvironmentDescriptor(ShellEnvironmentDescriptor):
"""Shellctl-specific descriptor carrying the workspace path and session id."""
workspace_cwd: str
session_id: str
def validate(self) -> None:
if not _SESSION_ID_PATTERN.fullmatch(self.session_id):
raise ValueError(f"Invalid session_id in reattach descriptor: {self.session_id!r}.")
expected_workspace = f"{_WORKSPACE_ROOT}/{self.session_id}"
if self.workspace_cwd != expected_workspace:
raise ValueError(
f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}, "
f"got {self.workspace_cwd!r}."
)
class ShellctlJobResult(Protocol):
"""Structural shape of one shellctl job result the adapter relies on.
Mirrors the fields the adapter reads from ``shell_session_manager`` job
results without importing the concrete type, so the merged output stream,
paging offset, completion flag, and exit status stay duck-typed.
"""
job_id: str
done: bool
output: str
offset: int
truncated: bool
exit_code: int | None
class ShellctlJobStatus(Protocol):
"""Structural shape of one shellctl status-only result (no output stream).
Returned by ``terminate``; carries completion and exit status plus the
latest paging offset so the adapter can drain any remaining output.
"""
job_id: str
done: bool
offset: int
exit_code: int | None
class ShellctlClientProtocol(Protocol):
"""Boundary the shellctl adapter needs from a shell-session-manager client."""
async def run(
self,
script: str,
*,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
) -> ShellctlJobResult: ...
async def wait(
self,
job_id: str,
*,
offset: int,
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
) -> ShellctlJobResult: ...
async def input(
self,
job_id: str,
text: str,
*,
offset: int,
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
) -> ShellctlJobResult: ...
async def terminate(
self,
job_id: str,
grace_seconds: float = _DEFAULT_TERMINATE_GRACE_SECONDS,
) -> ShellctlJobStatus: ...
async def delete(self, job_id: str, *, force: bool = False) -> object: ...
async def close(self) -> None: ...
type ShellctlClientFactory = Callable[[], ShellctlClientProtocol]
class ShellctlExecutionResult:
"""Completed shellctl command result.
shellctl merges stderr into a single output stream, so ``stderr()`` is
always empty and the merged stream is reported as ``stdout()``.
"""
_stdout: str
_stderr: str
_exit_code: int | None
_truncated: bool
def __init__(
self,
*,
stdout: str,
stderr: str = "",
exit_code: int | None,
truncated: bool = False,
) -> None:
self._stdout = stdout
self._stderr = stderr
self._exit_code = exit_code
self._truncated = truncated
def stdout(self) -> str:
return self._stdout
def stderr(self) -> str:
return self._stderr
def exit_code(self) -> int | None:
return self._exit_code
def truncated(self) -> bool:
"""Whether the returned ``stdout`` may be incomplete.
``True`` means shellctl still reported more output past what was
captured when draining stopped (its per-window ``truncated`` flag on the
final window, e.g. the output-window cap was hit). Callers that need the
command's *entire* output must treat a truncated result as a failure or
re-read, rather than trusting ``stdout()`` as complete.
"""
return self._truncated
@dataclass(slots=True)
class ShellctlExecutor:
"""Runs commands in one provisioned shellctl workspace.
Conforms structurally to ``ShellExecutorProtocol``. ``execute`` drains the
command to completion (accumulating shellctl's paged output windows) and
best-effort deletes the finished job before returning. The executor is
single-environment and is not safe to share across workspaces.
"""
client: ShellctlClientProtocol
workspace_cwd: str
timeout: float = _DEFAULT_TIMEOUT_SECONDS
async def execute(
self,
command: str,
*,
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> ShellctlExecutionResult:
result = await self.client.run(
command,
cwd=cwd if cwd is not None else self.workspace_cwd,
env=env,
timeout=self.timeout,
)
output_parts = [result.output]
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
job_id = result.job_id
windows = 1
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
result = await self.client.wait(job_id, offset=offset, timeout=self.timeout)
output_parts.append(result.output)
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
windows += 1
if done:
await _delete_job_best_effort(self.client, job_id)
return ShellctlExecutionResult(
stdout="".join(output_parts),
exit_code=exit_code,
truncated=truncated,
)
@dataclass(slots=True)
class ShellctlFileTransfer:
"""Moves file bytes in and out of one provisioned shellctl workspace.
Conforms structurally to ``ShellFileTransferProtocol``. Transfers run as
workspace-scoped shellctl jobs over the merged text channel: uploads embed
base64 in the command and pipe it through ``base64 -d``; downloads emit the
file's base64 framed by sentinels so prompt/tmux noise can be stripped
before decoding. Because the encoded payload is embedded in the upload
command, very large files can exceed the shell argument limit; this
primitive targets ordinary control-plane file sizes, not bulk binary
transfer.
"""
client: ShellctlClientProtocol
workspace_cwd: str
timeout: float = _FILE_TRANSFER_TIMEOUT_SECONDS
async def upload(self, *, content: bytes, remote_path: str) -> None:
encoded = base64.b64encode(content).decode("ascii")
completed = await _run_to_completion(
self.client,
_upload_script(remote_path=remote_path, encoded=encoded),
cwd=self.workspace_cwd,
timeout=self.timeout,
)
if completed.exit_code != 0:
raise ShellFileTransferError(
f"Failed to upload to {remote_path!r}: exit_code={completed.exit_code}, "
f"output={_output_tail(completed.output)!r}"
)
async def download(self, *, remote_path: str) -> bytes:
completed = await _run_to_completion(
self.client,
_download_script(remote_path=remote_path),
cwd=self.workspace_cwd,
timeout=self.timeout,
)
if completed.exit_code == _DOWNLOAD_MISSING_EXIT_CODE:
raise ShellFileTransferError(f"File not found in workspace: {remote_path!r}.")
if completed.exit_code != 0:
raise ShellFileTransferError(
f"Failed to download {remote_path!r}: exit_code={completed.exit_code}, "
f"output={_output_tail(completed.output)!r}"
)
framed = _extract_framed_payload(completed.output)
try:
return base64.b64decode("".join(framed.split()), validate=True)
except (binascii.Error, ValueError) as exc:
raise ShellFileTransferError(f"Failed to decode downloaded file {remote_path!r}: {exc}") from exc
@dataclass(slots=True)
class ShellctlHandle:
"""Live reference to one provisioned shellctl workspace.
Conforms structurally to ``ShellHandle``. Owns the shellctl ``client`` and
the allocated ``workspace_cwd`` until the provisioner destroys it.
``get_executor`` returns a fresh executor bound to this workspace each call.
"""
client: ShellctlClientProtocol
workspace_cwd: str
session_id: str
def descriptor(self) -> ShellctlEnvironmentDescriptor:
return ShellctlEnvironmentDescriptor(workspace_cwd=self.workspace_cwd, session_id=self.session_id)
async def get_executor(self) -> ShellctlExecutor:
return ShellctlExecutor(client=self.client, workspace_cwd=self.workspace_cwd)
async def get_file_transfer(self) -> ShellctlFileTransfer:
return ShellctlFileTransfer(client=self.client, workspace_cwd=self.workspace_cwd)
@dataclass(slots=True)
class ShellctlProvisioner:
"""Provisions isolated shellctl workspaces, one client per environment.
Conforms structurally to ``ShellProvisionProtocol``.
"""
client_factory: ShellctlClientFactory
timeout: float = _DEFAULT_TIMEOUT_SECONDS
async def provision(self) -> ShellctlHandle:
client = self.client_factory()
session_id = _generate_session_id()
workspace_cwd = f"{_WORKSPACE_ROOT}/{session_id}"
try:
completed = await _run_to_completion(client, _mkdir_script(session_id), cwd=None, timeout=self.timeout)
except BaseException:
await client.close()
raise
if completed.exit_code != 0:
await client.close()
raise ShellProvisionError(
f"Failed to create shell workspace {workspace_cwd}: mkdir exited with code {completed.exit_code}."
)
return ShellctlHandle(client=client, workspace_cwd=workspace_cwd, session_id=session_id)
async def reattach(self, descriptor: ShellctlEnvironmentDescriptor) -> ShellctlHandle:
"""Rebuild a live handle for an existing workspace without re-allocating it.
Opens a fresh shellctl client and points it at the workspace recorded in
``descriptor``. No ``mkdir`` is issued: the workspace is assumed to still
exist from the original ``provision``. Used on snapshot resume so a run
can keep executing in and eventually clean up its prior workspace.
"""
client = self.client_factory()
return ShellctlHandle(
client=client,
workspace_cwd=descriptor.workspace_cwd,
session_id=descriptor.session_id,
)
async def destroy(self, handle: ShellHandle[ShellctlEnvironmentDescriptor]) -> None:
if not isinstance(handle, ShellctlHandle):
raise TypeError("ShellctlProvisioner can only destroy handles it provisioned.")
try:
completed = await _run_to_completion(
handle.client, _cleanup_script(handle.session_id), cwd=None, timeout=self.timeout
)
if completed.exit_code != 0:
logger.warning(
"Shell workspace cleanup for session %s exited with code %s.",
handle.session_id,
completed.exit_code,
)
except (RuntimeError, ValueError) as exc:
logger.warning("Failed to remove shell workspace for session %s: %s", handle.session_id, exc)
finally:
await handle.client.close()
def create_default_shellctl_client_factory(*, entrypoint: str, token: str) -> ShellctlClientFactory:
"""Return a factory that builds a real shell-session-manager shellctl client.
The concrete client is imported lazily so importing this module does not
require the private ``shell-session-manager`` package. An explicit empty
``token`` is forwarded as-is to avoid the client falling back to ambient
process credentials.
"""
def factory() -> ShellctlClientProtocol:
from shell_session_manager.shellctl.client import ShellctlClient
return ShellctlClient(entrypoint, token=token)
return factory
@dataclass(slots=True)
class _CompletedJob:
"""Drained result of one internal shellctl job: merged output plus exit code."""
output: str
exit_code: int | None
async def _run_to_completion(
client: ShellctlClientProtocol,
script: str,
*,
cwd: str | None,
timeout: float,
) -> _CompletedJob:
"""Run one internal lifecycle script to completion, returning output and exit code."""
result = await client.run(script, cwd=cwd, env=None, timeout=timeout)
output_parts = [result.output]
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
job_id = result.job_id
windows = 1
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
result = await client.wait(job_id, offset=offset, timeout=timeout)
output_parts.append(result.output)
done = result.done
truncated = result.truncated
offset = result.offset
exit_code = result.exit_code
windows += 1
if done:
await _delete_job_best_effort(client, job_id)
return _CompletedJob(output="".join(output_parts), exit_code=exit_code)
async def _delete_job_best_effort(client: ShellctlClientProtocol, job_id: str) -> None:
"""Force-delete one shellctl job, never failing the caller on cleanup errors."""
try:
_ = await client.delete(job_id, force=True)
except Exception as exc: # noqa: BLE001 - best-effort teardown must not surface cleanup errors
logger.warning("Failed to delete shellctl job %s: %s", job_id, exc)
def _generate_session_id() -> str:
"""Return a shell-safe random session id used as the workspace directory name."""
return secrets.token_hex(8)
def _mkdir_script(session_id: str) -> str:
return f'mkdir -p "$HOME/workspace/{session_id}"'
def _cleanup_script(session_id: str) -> str:
return f'rm -rf -- "$HOME/workspace/{session_id}"'
def _upload_script(*, remote_path: str, encoded: str) -> str:
"""Return a script that recreates a file from embedded base64 in the workspace."""
quoted = _shquote(remote_path)
return f"mkdir -p \"$(dirname -- {quoted})\" && printf %s '{encoded}' | base64 -d > {quoted}"
def _download_script(*, remote_path: str) -> str:
"""Return a script that emits a file's base64 between transfer sentinels."""
quoted = _shquote(remote_path)
return (
f"if [ ! -f {quoted} ]; then exit {_DOWNLOAD_MISSING_EXIT_CODE}; fi; "
f"printf %s {_shquote(_TRANSFER_BEGIN)}; "
f'base64 < {quoted} | tr -d "\\n"; '
f"printf %s {_shquote(_TRANSFER_END)}"
)
def _extract_framed_payload(output: str) -> str:
"""Return the base64 text framed by the transfer sentinels in shellctl output."""
begin = output.find(_TRANSFER_BEGIN)
end = output.find(_TRANSFER_END, begin + len(_TRANSFER_BEGIN)) if begin != -1 else -1
if begin == -1 or end == -1:
raise ShellFileTransferError("download command returned no framed payload")
return output[begin + len(_TRANSFER_BEGIN) : end]
def _shquote(value: str) -> str:
"""Single-quote a value for POSIX shells, escaping embedded single quotes."""
return "'" + value.replace("'", "'\\''") + "'"
def _output_tail(output: str, *, limit: int = 500) -> str:
"""Return the trailing slice of command output for compact error messages."""
return output[-limit:]
__all__ = [
"ShellFileTransferError",
"ShellProvisionError",
"ShellctlClientFactory",
"ShellctlClientProtocol",
"ShellctlEnvironmentDescriptor",
"ShellctlExecutionResult",
"ShellctlExecutor",
"ShellctlFileTransfer",
"ShellctlHandle",
"ShellctlJobResult",
"ShellctlJobStatus",
"ShellctlProvisioner",
"create_default_shellctl_client_factory",
]
+251 -140
View File
@@ -1,20 +1,28 @@
"""Shell layer backed by the shell adapter provisioner/executor mechanism.
"""Shellctl-backed Dify shell layer.
``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly
``shell_run``, ``shell_wait``, ``shell_input``, and ``shell_interrupt``. The
layer persists only JSON-safe shell session state in ``runtime_state`` and keeps
its live ``ShellctlHandle`` on the layer instance only while
its live shellctl HTTP client on the layer instance only while
``resource_context()`` is active. Agenton enters that resource scope before
``on_context_create`` or ``on_context_resume`` and exits it after
``on_context_suspend`` or ``on_context_delete``, so business hooks and shell
tools can rely on live resources without ever serializing them into snapshots.
tools can rely on a live client without ever serializing it into snapshots.
The layer delegates workspace lifecycle to ``ShellProvisionProtocol``:
``provision`` allocates a fresh workspace, ``reattach`` rebuilds a live handle
for an existing workspace from a serialized descriptor, and ``destroy`` tears
the workspace down. User-facing shell tools call the shellctl client obtained
from the handle directly; trusted server-owned scripts go through
``ShellctlExecutor`` which auto-cleans completed jobs.
The runtime state tracks shellctl job ids for both user-visible shell jobs and
internal lifecycle jobs such as workspace mkdir/cleanup commands. Those internal
jobs are intentionally not deleted ad hoc; shellctl job-state deletion is
centralized in ``on_context_delete`` so one lifecycle hook owns exit-time
cleanup for successful create/resume flows. If ``on_context_create`` or a later
side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
Agenton still exits ``resource_context()`` but never transitions the layer to
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
so the enter hook itself must perform best-effort business compensation before
re-raising the failure. Agent Soul shell env is injected into user-visible
commands and CLI bootstrap commands without persisting a workspace env file.
Agent Stub env injection uses shellctl's native per-run ``env`` argument for
user-visible ``shell.run`` and for trusted server-owned fixed scripts executed
through ``run_remote_script()``.
"""
from __future__ import annotations
@@ -24,23 +32,24 @@ from contextlib import asynccontextmanager
import json
import logging
import re
import secrets
import time
from dataclasses import dataclass
from typing import ClassVar, NotRequired, Protocol, TypedDict, cast
from typing import ClassVar, NotRequired, Protocol, TypedDict
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
from pydantic_ai import Tool
from shell_session_manager.shellctl.client import ShellctlClientError
from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError
from shell_session_manager.shellctl.shared import (
DEFAULT_TERMINATE_GRACE_SECONDS,
DEFAULT_TIMEOUT_SECONDS,
DeleteJobResponse,
JobResult,
JobStatusView,
)
from typing_extensions import Self, override
from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
from dify_agent.adapters.shell.shellctl import ShellctlEnvironmentDescriptor, ShellctlExecutor, ShellctlHandle
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
@@ -49,6 +58,12 @@ from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellL
logger = logging.getLogger(__name__)
_WORKSPACE_ROOT = "~/workspace"
_WORKSPACE_COLLISION_EXIT_CODE = 17
_SESSION_TIME_HEX_MASK = 0xFFFFF
_SESSION_RANDOM_HEX_LENGTH = 2
_SESSION_ID_ATTEMPT_LIMIT = 256
_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$")
_REMOTE_COMMAND_MAX_OUTPUT_WINDOWS = 64
_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools:
1. shell_run
@@ -206,7 +221,7 @@ class ShellctlClientProtocol(Protocol):
*,
force: bool = False,
grace_seconds: float | None = None,
) -> object: ...
) -> DeleteJobResponse: ...
async def close(self) -> None: ...
@@ -222,9 +237,12 @@ class DifyShellRuntimeState(BaseModel):
created before suspension. Callers should replace the stored list/dict values
rather than mutating them in place so Pydantic assignment validation keeps
guarding the serialized state. Hydrated public snapshots must keep
``session_id`` and ``workspace_cwd`` consistent with the descriptor returned
by the shell provisioner, so resume and delete paths cannot escape the
isolated workspace root or inject shell syntax into lifecycle commands.
``session_id`` in the proposal's safe lowercase-hex format and must keep
``workspace_cwd`` exactly aligned with ``~/workspace/<session_id>`` so resume
and delete paths cannot escape the isolated workspace root or inject shell
syntax into lifecycle commands. Shellctl job ids remain opaque strings here;
the layer only enforces uniqueness plus the invariant that any stored offset
entry must belong to a tracked job id in the same runtime state.
"""
session_id: str | None = None
@@ -237,12 +255,10 @@ class DifyShellRuntimeState(BaseModel):
@field_validator("session_id")
@classmethod
def validate_session_id(cls, value: str | None) -> str | None:
"""Reject session ids that could escape the workspace root or inject shell syntax."""
"""Accept only the short lowercase-hex session ids defined by the proposal."""
if value is None:
return value
if not re.fullmatch(r"[0-9a-f]{7,16}", value):
raise ValueError("session_id must be 7 to 16 lowercase hex characters (got an invalid value).")
return value
return _validated_session_id(value)
@field_validator("job_ids")
@classmethod
@@ -270,28 +286,24 @@ class DifyShellRuntimeState(BaseModel):
@dataclass(frozen=True, slots=True)
class RemoteCommandResult:
"""Completed remote sandbox command returned to server-owned callers.
Only fields with live consumers are kept: ``output``/``exit_code`` (read by
every caller), ``truncated`` (drive pull treats a truncated result as a
failure because it needs the command's full output), and ``status`` (used in
drive's human-readable error message). shellctl paging details such as the
job id, completion flag, byte offset, and output path are intentionally not
surfaced here, since no caller reads them.
"""
"""Completed remote sandbox command returned to server-owned callers."""
job_id: str
status: str
done: bool
exit_code: int | None
output: str
offset: int
truncated: bool
output_path: str
@dataclass(slots=True)
class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
"""Shell tool layer backed by the shell provisioner/executor mechanism.
"""Shell tool layer backed by a live shellctl client while active.
The mutable serializable state lives in ``runtime_state``; the live
``ShellctlHandle`` is intentionally kept off-snapshot. Tool methods update
The mutable serializable state lives in ``runtime_state``; the live client is
intentionally kept off-snapshot in ``_shellctl_client``. Tool methods update
tracked job ids and output offsets after every successful shellctl response so
later ``shell_wait``/``shell_input`` calls can resume from the last known
offset without exposing offsets as model-controlled inputs.
@@ -300,35 +312,39 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID
config: DifyShellLayerConfig
shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor]
shellctl_entrypoint: str
shellctl_client_factory: ShellctlClientFactory
agent_stub_api_base_url: str | None = None
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
_shell_handle: ShellctlHandle | None = None
_shellctl_client: ShellctlClientProtocol | None = None
@classmethod
@override
def from_config(cls, config: DifyShellLayerConfig) -> Self:
"""Reject construction that omits the shell provisioner."""
"""Reject construction that omits server-injected shellctl settings."""
del config
raise TypeError("DifyShellLayer requires a shell provisioner and must use a provider factory.")
raise TypeError("DifyShellLayer requires server-side shellctl settings and must use a provider factory.")
@classmethod
def from_config_with_settings(
cls,
config: DifyShellLayerConfig,
*,
shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor] | None,
shellctl_entrypoint: str | None,
shellctl_client_factory: ShellctlClientFactory,
agent_stub_api_base_url: str | None = None,
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
) -> Self:
"""Create the layer from public config plus shell provisioner settings."""
if shell_provisioner is None:
"""Create the layer from public config plus server-only shell settings."""
normalized_entrypoint = (shellctl_entrypoint or "").strip()
if not normalized_entrypoint:
raise ValueError(
"DifyShellLayer requires a non-null shell provisioner when the 'dify.shell' layer is used."
"DifyShellLayer requires a non-empty DIFY_AGENT_SHELLCTL_ENTRYPOINT when the 'dify.shell' layer is used."
)
layer = cls(
config=config,
shell_provisioner=shell_provisioner,
shellctl_entrypoint=normalized_entrypoint,
shellctl_client_factory=shellctl_client_factory,
agent_stub_api_base_url=agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory,
)
@@ -353,90 +369,107 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
@override
@asynccontextmanager
async def resource_context(self) -> AsyncGenerator[None]:
"""Hold the live shell handle scope.
"""Hold one live shellctl client for one active Agenton layer scope.
The actual handle is set in ``on_context_create`` /
``on_context_resume``. This scope ensures cleanup if a lifecycle hook
fails before the handle is set.
The shellctl client is a non-serializable live resource, so Agenton owns
only the timing of this scope, not the client itself. Business hooks and
tools should call ``_require_client()`` to ensure they are running inside
an active resource scope.
"""
if self._shellctl_client is not None:
raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.")
client = self.shellctl_client_factory(self.shellctl_entrypoint)
self._shellctl_client = client
try:
yield
finally:
self._shell_handle = None
self._shellctl_client = None
await client.close()
@override
async def on_context_create(self) -> None:
"""Provision a new workspace session using the shell provisioner.
"""Allocate a new workspace session using the active live shellctl client.
The provisioner allocates the workspace directory and returns a
``ShellctlHandle``. The layer then bootstraps the workspace with Agent
Soul env exports and CLI tool install commands. If workspace setup
partially succeeds and this hook later raises, the layer never becomes
``ACTIVE``. In that path Agenton still exits ``resource_context()``, but
``on_context_delete()`` will not run, so this hook must clean up any
tracked artifacts before re-raising.
If workspace setup partially succeeds and this hook later raises, the
layer never becomes ``ACTIVE``. In that path Agenton still exits
``resource_context()``, but ``on_context_delete()`` will not run, so this
hook must clean up any tracked shellctl job artifacts before re-raising.
"""
try:
handle = cast(ShellctlHandle, await self.shell_provisioner.provision())
self._shell_handle = handle
descriptor = handle.descriptor()
await self._bootstrap_workspace(descriptor.workspace_cwd)
_ = self._require_client()
session_id, workspace_cwd = await self._allocate_workspace()
await self._bootstrap_workspace(workspace_cwd)
except BaseException:
await self._cleanup_create_failure()
raise
self.runtime_state = DifyShellRuntimeState.model_validate(
{
**self.runtime_state.model_dump(mode="python"),
"session_id": descriptor.session_id,
"workspace_cwd": descriptor.workspace_cwd,
"session_id": session_id,
"workspace_cwd": workspace_cwd,
}
)
@override
async def on_context_resume(self) -> None:
"""Reattach to an existing serialized shell session.
"""Resume an existing serialized shell session inside an active resource scope.
Builds a ``ShellEnvironmentDescriptor`` from the persisted runtime state
and asks the provisioner to reattach without allocating a new workspace.
If a future resume path adds self-heal side effects before raising, this
hook must compensate for them itself because failed resume attempts never
transition the slot back to ``ACTIVE``.
transition the slot back to ``ACTIVE`` and therefore do not receive a
normal suspend/delete hook.
"""
session_id, workspace_cwd = self._require_session_identity()
descriptor = ShellctlEnvironmentDescriptor(
workspace_cwd=workspace_cwd,
session_id=session_id,
)
handle = cast(ShellctlHandle, await self.shell_provisioner.reattach(descriptor))
self._shell_handle = handle
_ = self._require_client()
_ = self._require_session_identity()
@override
async def on_context_suspend(self) -> None:
"""Close the live client so it does not leak across snapshot boundaries.
"""Preserve workspace and job state while the live client remains active.
``reattach`` on the next resume creates a fresh client pointing at the
same workspace. ``resource_context()`` clears the handle reference after
this hook returns.
``resource_context()`` owns client teardown after this hook returns.
"""
handle = self._shell_handle
if handle is not None:
await handle.client.close()
_ = self._require_client()
@override
async def on_context_delete(self) -> None:
"""Best-effort cleanup for tracked shellctl jobs and workspace deletion.
"""Best-effort cleanup for workspace deletion and tracked shellctl jobs.
Tracked shellctl jobs are force-deleted on a best-effort basis before the
handle is destroyed, since job records may outlive the workspace. The
provisioner's ``destroy`` handles workspace removal and client close.
Workspace removal must happen before tracked shellctl job deletion because
the cleanup itself is implemented as an internal shellctl run. That means
deleting job state first would prevent the layer from issuing the
proposal-required ``rm -rf`` cleanup job and then cleaning up that final
job record along with the rest of the session's tracked shellctl state.
``resource_context()`` closes the live client only after this hook
finishes.
"""
handle = self._shell_handle
if handle is None:
return
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
_ = self._require_client()
cleanup_job_id: str | None = None
identity = self._try_session_identity()
if identity is not None:
session_id, _workspace_cwd = identity
try:
cleanup_result = await self._run_internal_job_to_completion(
_workspace_cleanup_script(session_id=session_id),
cwd=None,
)
cleanup_job_id = cleanup_result["job_id"]
if cleanup_result["exit_code"] != 0:
logger.warning(
"Shell workspace cleanup job %s for session %s exited with code %s.",
cleanup_job_id,
session_id,
cleanup_result["exit_code"],
)
except (RuntimeError, ValueError, ShellctlClientError) as exc:
logger.warning("Failed to remove shell workspace for session %s: %s", session_id, exc)
tracked_job_ids = _deduplicate_preserving_order(
[*self.runtime_state.job_ids, *([cleanup_job_id] if cleanup_job_id is not None else [])]
)
await self._delete_tracked_jobs_best_effort(tracked_job_ids)
self._clear_tracked_jobs()
await self.shell_provisioner.destroy(handle)
self._shell_handle = None
async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
"""Start a new shell job inside the session workspace."""
@@ -500,10 +533,8 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
"""Run one trusted server-side script inside the sandbox workspace.
The sandbox file service uses this boundary for fixed list/read/upload
helpers. Execution, output draining, and transient shellctl job cleanup
are delegated to ``ShellctlExecutor`` from the shell adapter; the layer
owns only the optional Agent Stub env injection and the
``RemoteCommandResult`` mapping.
helpers. The layer owns output paging, transient shellctl job cleanup,
and optional Agent Stub env injection.
Unlike model-visible ``shell.run``, this server-owned boundary does not
inject Agent Soul shell env. Keeping the user-controlled shell env out
@@ -515,32 +546,28 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
env = self._build_user_shell_run_env()
if env is None:
raise RuntimeError("Agent Stub environment injection is not available for this shell session.")
handle = self._require_handle()
executor = ShellctlExecutor(
client=handle.client, # pyright: ignore[reportArgumentType]
workspace_cwd=self._require_workspace_cwd(),
return await self._run_remote_job_to_completion(
script,
timeout=timeout,
)
result = await executor.execute(script, env=env)
return RemoteCommandResult(
status="exited" if not result.truncated() else "running",
exit_code=result.exit_code(),
output=result.stdout(),
truncated=result.truncated(),
env=env,
)
def environment_descriptor(self) -> ShellctlEnvironmentDescriptor:
"""Return the serializable workspace seed for the shell adapter.
Bridges this layer's ``runtime_state`` to
``dify_agent.adapters.shell``: the returned descriptor identifies the
session workspace so an adapter ``ShellProvisionProtocol.reattach`` can
rebuild a live handle pointing at it without re-allocating, and without
re-entering this layer. Raises ``ValueError`` if the session identity is
missing or inconsistent.
"""
session_id, workspace_cwd = self._require_session_identity()
return ShellctlEnvironmentDescriptor(workspace_cwd=workspace_cwd, session_id=session_id)
async def _allocate_workspace(self) -> tuple[str, str]:
"""Allocate a unique ``~/workspace/<session_id>`` directory by mkdir collision checks."""
for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT):
session_id = _generate_session_id()
mkdir_result = await self._run_internal_job_to_completion(
_workspace_mkdir_script(session_id=session_id),
cwd=None,
)
if mkdir_result["exit_code"] == _WORKSPACE_COLLISION_EXIT_CODE:
continue
if mkdir_result["exit_code"] != 0:
raise RuntimeError(
f"Failed to create shell workspace {_workspace_cwd(session_id)}: {mkdir_result['status']} exit_code={mkdir_result['exit_code']}"
)
return session_id, _workspace_cwd(session_id)
raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.")
async def _bootstrap_workspace(self, workspace_cwd: str) -> None:
"""Apply Agent Soul shell config to the freshly-created workspace."""
@@ -554,24 +581,21 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
)
async def _cleanup_create_failure(self) -> None:
"""Best-effort cleanup for create failures before ACTIVE state.
"""Best-effort shellctl job cleanup for create failures before ACTIVE state.
Agenton only calls ``on_context_delete`` for layers that successfully
entered ``ACTIVE``. If ``on_context_create`` fails after issuing
internal jobs, those tracked job artifacts would otherwise leak because
no later lifecycle hook owns them. The provisioner's ``destroy`` handles
workspace removal and client close.
entered ``ACTIVE``. If ``on_context_create`` fails after issuing one or
more internal shellctl jobs, those tracked job artifacts would otherwise
leak because no later lifecycle hook owns them. ``resource_context()``
still closes the live client for this failed enter attempt after the hook
unwinds.
"""
handle = self._shell_handle
if handle is None:
if not self.runtime_state.job_ids:
return
if self.runtime_state.job_ids:
try:
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
finally:
self._clear_tracked_jobs()
await self.shell_provisioner.destroy(handle)
self._shell_handle = None
try:
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
finally:
self._clear_tracked_jobs()
async def _run_internal_job_to_completion(
self,
@@ -592,18 +616,56 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
self._track_job_result(result)
return _job_result_observation(result)
def _require_handle(self) -> ShellctlHandle:
"""Return the live handle or reject tool/lifecycle use without one."""
if self._shell_handle is None:
raise RuntimeError(
"DifyShellLayer requires an active shell handle inside resource_context(); "
+ "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()."
async def _run_remote_job_to_completion(
self,
script: str,
*,
timeout: float,
env: dict[str, str] | None,
) -> RemoteCommandResult:
"""Run a workspace-scoped script to completion and delete its job state.
Shellctl's ``truncated`` flag is per output window: it means the caller
should continue from the returned offset. After this helper drains those
windows, only the final window can describe whether output is still
unread, usually because the safety window cap was reached.
"""
client = self._require_client()
job_id: str | None = None
try:
result = await client.run(script, cwd=self._require_workspace_cwd(), env=env, timeout=timeout)
job_id = result.job_id
self._track_job_result(result)
output_parts = [result.output]
windows = 1
while (result.truncated or not result.done) and windows < _REMOTE_COMMAND_MAX_OUTPUT_WINDOWS:
result = await client.wait(result.job_id, offset=self._tracked_offset(result.job_id), timeout=timeout)
self._track_job_result(result)
output_parts.append(result.output)
windows += 1
return RemoteCommandResult(
job_id=result.job_id,
status=result.status.value,
done=result.done,
exit_code=result.exit_code,
output="".join(output_parts),
offset=result.offset,
truncated=result.truncated,
output_path=result.output_path,
)
return self._shell_handle
finally:
if job_id is not None:
await self._delete_job_best_effort(job_id)
self._forget_tracked_job(job_id)
def _require_client(self) -> ShellctlClientProtocol:
"""Return the live shellctl client from the handle."""
return cast(ShellctlClientProtocol, self._require_handle().client)
"""Return the live client or reject tool/lifecycle use without one."""
if self._shellctl_client is None:
raise RuntimeError(
"DifyShellLayer requires an active shellctl client inside resource_context(); "
+ "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()."
)
return self._shellctl_client
def _require_workspace_cwd(self) -> str:
"""Return the configured workspace directory for user-facing shell jobs."""
@@ -723,6 +785,15 @@ def _shell_layer_prefix_prompt() -> str:
return _SHELL_LAYER_PREFIX_PROMPT
def create_shellctl_client_factory(*, token: str) -> ShellctlClientFactory:
"""Return the default shellctl client factory used by server-side providers."""
def factory(entrypoint: str) -> ShellctlClientProtocol:
return ShellctlClient(entrypoint, token=token)
return factory
def _job_result_observation(result: JobResult) -> ShellJobObservation:
return {
"job_id": result.job_id,
@@ -753,8 +824,16 @@ def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObs
return result
def _generate_session_id() -> str:
time_component = int(time.time()) & _SESSION_TIME_HEX_MASK
random_component = secrets.token_hex(1)
if len(random_component) != _SESSION_RANDOM_HEX_LENGTH:
raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.")
return f"{time_component:05x}{random_component}"
def _workspace_cwd(session_id: str) -> str:
return f"{_WORKSPACE_ROOT}/{session_id}"
return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}"
def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
@@ -798,11 +877,41 @@ def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str:
return "\n".join([*lines, script])
def _workspace_mkdir_script(*, session_id: str) -> str:
"""Return the internal mkdir command used for proposal-defined collision checks.
The parent ``$HOME/workspace`` directory is created with ``mkdir -p`` so it
can already exist, but the final session directory intentionally uses plain
``mkdir``. That second call is the collision detector: when the target
already exists, the script maps that case to ``_WORKSPACE_COLLISION_EXIT_CODE``
so ``on_context_create()`` can retry with a different random suffix instead
of silently reusing another session's workspace.
"""
safe_session_id = _validated_session_id(session_id)
workspace_dir = f"$HOME/workspace/{safe_session_id}"
return (
'mkdir -p "$HOME/workspace"; '
f'if mkdir "{workspace_dir}"; then exit 0; fi; '
f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; '
"exit 1"
)
def _workspace_cleanup_script(*, session_id: str) -> str:
return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"'
def _shquote(value: str) -> str:
"""Single-quote a value for POSIX shells, escaping embedded single quotes."""
return "'" + value.replace("'", "'\\''") + "'"
def _validated_session_id(session_id: str) -> str:
if not _SESSION_ID_PATTERN.fullmatch(session_id):
raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.")
return session_id
def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
@@ -819,5 +928,7 @@ __all__ = [
"DifyShellLayer",
"DifyShellRuntimeState",
"RemoteCommandResult",
"ShellctlClientFactory",
"ShellctlClientProtocol",
"create_shellctl_client_factory",
]
@@ -44,10 +44,8 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer
from dify_agent.layers.output.output_layer import DifyOutputLayer
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.factory import create_shell_provisioner
from dify_agent.layers.shell.configs import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory
type DifyAgentLayerProvider = LayerProvider[Any]
@@ -65,11 +63,13 @@ def create_default_layer_providers(
) -> tuple[DifyAgentLayerProvider, ...]:
"""Return the server provider set of safe config-constructible layers.
``shellctl_auth_token`` defaults to no token. An explicit empty string
prevents ``ShellctlClient`` from falling back to the Dify Agent process's
``SHELLCTL_AUTH_TOKEN`` environment variable; deployments that enable
shellctl bearer auth must set the Dify Agent server setting explicitly.
``shellctl_auth_token`` defaults to no token. Passing an explicit empty string
to ``create_shellctl_client_factory`` prevents ``ShellctlClient`` from falling
back to the Dify Agent process's ``SHELLCTL_AUTH_TOKEN`` environment variable;
deployments that enable shellctl bearer auth must set the Dify Agent server
setting explicitly.
"""
shellctl_token = shellctl_auth_token or ""
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
if agent_stub_token_codec is not None:
@@ -102,15 +102,8 @@ def create_default_layer_providers(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shell_provisioner=create_shell_provisioner(
ShellAdapterSettings(
shell_provider="shellctl",
shellctl_entrypoint=shellctl_entrypoint,
shellctl_auth_token=shellctl_auth_token,
)
)
if shellctl_entrypoint
else None,
shellctl_entrypoint=shellctl_entrypoint,
shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token),
agent_stub_api_base_url=agent_stub_api_base_url,
agent_stub_token_factory=agent_stub_token_factory,
),
@@ -1,331 +0,0 @@
"""Local tests for the shellctl shell adapter and env-driven provider factory.
These exercise the provider-agnostic boundary contract (provision/execute/wait,
file transfer, optional input/interrupt) against a fake shellctl client, plus the
``DIFY_AGENT_SHELL_PROVIDER`` selection in the factory. They avoid the private
``shell-session-manager`` package by injecting a structural fake client.
"""
import asyncio
import base64
import secrets
from collections.abc import Callable
from dataclasses import dataclass, field
import pytest
from dify_agent.adapters.shell import shellctl
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.factory import create_shell_provisioner
from dify_agent.adapters.shell.protocols import (
ShellEnvironmentDescriptor,
)
from dify_agent.adapters.shell.shellctl import (
ShellctlEnvironmentDescriptor,
ShellctlProvisioner,
ShellFileTransferError,
ShellProvisionError,
)
_SESSION_HEX = "deadbeefdeadbeef"
_WORKSPACE_CWD = f"~/workspace/{_SESSION_HEX}"
@dataclass(slots=True)
class _Job:
job_id: str
done: bool = True
output: str = ""
offset: int = 0
truncated: bool = False
exit_code: int | None = 0
@dataclass(slots=True)
class _Status:
job_id: str
done: bool = True
offset: int = 0
exit_code: int | None = 0
@dataclass(slots=True)
class _RunCall:
script: str
cwd: str | None
env: dict[str, str] | None
type _RunHandler = Callable[[str, str | None, dict[str, str] | None], _Job]
type _WaitHandler = Callable[[str, int], _Job]
type _InputHandler = Callable[[str, str, int], _Job]
type _TerminateHandler = Callable[[str], _Status]
@dataclass(slots=True)
class FakeShellctlClient:
"""Structural shellctl client double recording calls and replaying handlers."""
run_handler: _RunHandler | None = None
wait_handler: _WaitHandler | None = None
input_handler: _InputHandler | None = None
terminate_handler: _TerminateHandler | None = None
run_calls: list[_RunCall] = field(default_factory=list)
wait_calls: list[tuple[str, int]] = field(default_factory=list)
input_calls: list[tuple[str, str, int]] = field(default_factory=list)
terminate_calls: list[tuple[str, float]] = field(default_factory=list)
delete_calls: list[str] = field(default_factory=list)
closed: bool = False
async def run(self, script, *, cwd=None, env=None, timeout=30.0):
del timeout
self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env))
if self.run_handler is not None:
return self.run_handler(script, cwd, env)
return _Job(job_id="job", done=True, exit_code=0)
async def wait(self, job_id, *, offset, timeout=30.0):
del timeout
self.wait_calls.append((job_id, offset))
if self.wait_handler is not None:
return self.wait_handler(job_id, offset)
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
async def input(self, job_id, text, *, offset, timeout=30.0):
del timeout
self.input_calls.append((job_id, text, offset))
if self.input_handler is not None:
return self.input_handler(job_id, text, offset)
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
async def terminate(self, job_id, grace_seconds=10.0):
self.terminate_calls.append((job_id, grace_seconds))
if self.terminate_handler is not None:
return self.terminate_handler(job_id)
return _Status(job_id=job_id, done=True, exit_code=130)
async def delete(self, job_id, *, force=False):
del force
self.delete_calls.append(job_id)
return None
async def close(self):
self.closed = True
@pytest.fixture(autouse=True)
def _fixed_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: _SESSION_HEX)
def _provisioner(client: FakeShellctlClient) -> ShellctlProvisioner:
return ShellctlProvisioner(client_factory=lambda: client)
def test_provision_allocates_workspace_and_execute_drains_merged_output() -> None:
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
if script.startswith("mkdir"):
assert cwd is None
return _Job(job_id="mkdir-job", done=True, exit_code=0)
assert cwd == _WORKSPACE_CWD
return _Job(job_id="user-job", done=False, output="par", offset=3, truncated=False, exit_code=None)
def wait_handler(job_id: str, offset: int) -> _Job:
assert job_id == "user-job"
assert offset == 3
return _Job(job_id="user-job", done=True, output="tial", offset=7, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
assert handle.workspace_cwd == _WORKSPACE_CWD
executor = await handle.get_executor()
result = await executor.execute("pwd", env={"FOO": "bar"})
assert result.stdout() == "partial"
assert result.stderr() == ""
assert result.exit_code() == 0
assert result.truncated() is False
asyncio.run(scenario())
assert client.run_calls[0].cwd is None
user_run = next(call for call in client.run_calls if call.script == "pwd")
assert user_run.env == {"FOO": "bar"}
# completed jobs (internal mkdir + user command) are self-cleaned.
assert "mkdir-job" in client.delete_calls
assert "user-job" in client.delete_calls
def test_execute_reports_truncated_when_output_window_cap_is_hit() -> None:
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del cwd, env
if script.startswith("mkdir"):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
return _Job(job_id="user-job", done=False, output="x", offset=1, truncated=True, exit_code=None)
def wait_handler(job_id: str, offset: int) -> _Job:
return _Job(job_id=job_id, done=False, output="x", offset=offset + 1, truncated=True, exit_code=None)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
async def scenario() -> bool:
handle = await _provisioner(client).provision()
executor = await handle.get_executor()
result = await executor.execute("tail -f log")
return result.truncated()
assert asyncio.run(scenario()) is True
# a job that never completed is left intact (not deleted/forgotten).
assert "user-job" not in client.delete_calls
def test_provision_failure_closes_client_and_raises() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=1)
)
async def scenario() -> None:
with pytest.raises(ShellProvisionError):
await _provisioner(client).provision()
asyncio.run(scenario())
assert client.closed is True
def test_destroy_runs_cleanup_in_default_cwd_then_closes_client() -> None:
client = FakeShellctlClient(run_handler=lambda _script, _cwd, _env: _Job(job_id="job", done=True, exit_code=0))
async def scenario() -> None:
provisioner = _provisioner(client)
handle = await provisioner.provision()
await provisioner.destroy(handle)
asyncio.run(scenario())
cleanup_call = client.run_calls[-1]
assert cleanup_call.cwd is None
assert _SESSION_HEX in cleanup_call.script and cleanup_call.script.startswith("rm -rf")
assert client.closed is True
def test_file_transfer_download_decodes_sentinel_framed_base64() -> None:
content = b"hello \x00 world"
encoded = base64.b64encode(content).decode("ascii")
framed = f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}trailing"
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
if script.startswith("mkdir"):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
assert cwd == _WORKSPACE_CWD
return _Job(job_id="dl-job", done=True, output=framed, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
transfer = await handle.get_file_transfer()
downloaded = await transfer.download(remote_path="report.txt")
assert downloaded == content
asyncio.run(scenario())
def test_file_transfer_download_missing_file_raises() -> None:
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del cwd, env
if script.startswith("mkdir"):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
return _Job(job_id="dl-job", done=True, output="", exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
transfer = await handle.get_file_transfer()
with pytest.raises(ShellFileTransferError, match="not found"):
await transfer.download(remote_path="missing.txt")
asyncio.run(scenario())
def test_file_transfer_upload_embeds_base64_and_succeeds() -> None:
content = b"payload-bytes"
encoded = base64.b64encode(content).decode("ascii")
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
if script.startswith('mkdir -p "$HOME'):
return _Job(job_id="mkdir-job", done=True, exit_code=0)
assert cwd == _WORKSPACE_CWD
assert encoded in script
return _Job(job_id="ul-job", done=True, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> None:
handle = await _provisioner(client).provision()
transfer = await handle.get_file_transfer()
await transfer.upload(content=content, remote_path="out.bin")
asyncio.run(scenario())
def test_provision_exposes_descriptor_seed() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=0)
)
async def scenario() -> ShellEnvironmentDescriptor:
handle = await _provisioner(client).provision()
return handle.descriptor()
descriptor = asyncio.run(scenario())
assert isinstance(descriptor, ShellctlEnvironmentDescriptor)
assert descriptor.workspace_cwd == _WORKSPACE_CWD
assert descriptor.session_id == _SESSION_HEX
def test_reattach_rebuilds_handle_without_mkdir_and_executes_in_same_workspace() -> None:
descriptor = ShellctlEnvironmentDescriptor(workspace_cwd=_WORKSPACE_CWD, session_id=_SESSION_HEX)
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
del env
assert not script.startswith("mkdir")
assert cwd == _WORKSPACE_CWD
return _Job(job_id="user-job", done=True, output="ok", offset=2, exit_code=0)
client = FakeShellctlClient(run_handler=run_handler)
async def scenario() -> str:
handle = await _provisioner(client).reattach(descriptor)
executor = await handle.get_executor()
result = await executor.execute("pwd")
return result.stdout()
assert asyncio.run(scenario()) == "ok"
# reattach must not allocate a new workspace.
assert all(not call.script.startswith("mkdir") for call in client.run_calls)
def test_factory_unknown_provider_raises() -> None:
settings = ShellAdapterSettings(shell_provider="nope")
with pytest.raises(ValueError, match="Unknown shell provider"):
create_shell_provisioner(settings)
def test_factory_shellctl_requires_entrypoint() -> None:
settings = ShellAdapterSettings(shell_provider="shellctl", shellctl_entrypoint=None)
with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
create_shell_provisioner(settings)
def test_factory_builds_shellctl_provisioner_from_settings() -> None:
settings = ShellAdapterSettings(
shell_provider="shellctl",
shellctl_entrypoint="http://shellctl.example",
)
provisioner = create_shell_provisioner(settings)
assert isinstance(provisioner, ShellctlProvisioner)
@@ -2,23 +2,25 @@
from __future__ import annotations
from typing import cast
import pytest
from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
from dify_agent.layers.shell import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult, ShellctlClientFactory
def _unused_client_factory():
def _unused_client_factory(_entrypoint: str):
raise AssertionError("shellctl client should not be used by these drive-layer tests")
def _shell_layer() -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
shell_provisioner=ShellctlProvisioner(client_factory=_unused_client_factory),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=cast(ShellctlClientFactory, _unused_client_factory),
)
@@ -57,10 +59,14 @@ def _remote_result(
truncated: bool = False,
) -> RemoteCommandResult:
return RemoteCommandResult(
job_id="remote-drive-pull",
status="exited",
done=True,
exit_code=exit_code,
output=output,
offset=len(output),
truncated=truncated,
output_path="/tmp/output.log",
)
@@ -1,6 +1,7 @@
import asyncio
from collections.abc import Callable, Mapping
import secrets
import time
from dataclasses import dataclass
from typing import cast
@@ -23,14 +24,8 @@ from dify_agent.layers.shell import (
DifyShellSandboxConfig,
DifyShellSecretRefConfig,
)
from dify_agent.adapters.shell.shellctl import (
ShellctlEnvironmentDescriptor,
ShellctlHandle,
ShellctlProvisioner,
ShellProvisionError,
)
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState
from shell_session_manager.shellctl.shared import JobResult, JobStatusName, JobStatusView
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState, ShellctlClientFactory
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
def _job_result(
@@ -121,6 +116,7 @@ class TerminateCall:
class DeleteCall:
job_id: str
force: bool
grace_seconds: float | None
class FakeShellctlClient:
@@ -139,7 +135,7 @@ class FakeShellctlClient:
wait_handler: Callable[[str, int, float], JobResult] | None = None,
input_handler: Callable[[str, str, int, float], JobResult] | None = None,
terminate_handler: Callable[[str, float], JobStatusView] | None = None,
delete_handler: Callable[[str, bool, float | None], object] | None = None,
delete_handler: Callable[[str, bool, float | None], DeleteJobResponse] | None = None,
) -> None:
self._run_handler = run_handler
self._wait_handler = wait_handler
@@ -194,22 +190,26 @@ class FakeShellctlClient:
job_id: str,
*,
force: bool = False,
) -> object:
self.delete_calls.append(DeleteCall(job_id=job_id, force=force))
grace_seconds: float | None = None,
) -> DeleteJobResponse:
self.delete_calls.append(DeleteCall(job_id=job_id, force=force, grace_seconds=grace_seconds))
self.events.append(("delete", job_id))
if self._delete_handler is None:
return None
return self._delete_handler(job_id, force, None)
return DeleteJobResponse(job_id=job_id)
return self._delete_handler(job_id, force, grace_seconds)
async def close(self) -> None:
self.closed = True
self.events.append(("close", "client"))
def _shell_layer(*, client: FakeShellctlClient, config: DifyShellLayerConfig | None = None) -> DifyShellLayer:
def _shell_layer(
*, client_factory: ShellctlClientFactory, config: DifyShellLayerConfig | None = None
) -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
config or DifyShellLayerConfig(),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=client_factory,
)
@@ -236,12 +236,13 @@ def _execution_context_layer() -> DifyExecutionContextLayer:
)
def _shell_provider(*, client: FakeShellctlClient) -> LayerProvider[DifyShellLayer]:
def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]:
return LayerProvider.from_factory(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=client_factory,
),
)
@@ -250,34 +251,31 @@ def test_shell_type_id_constant_matches_implementation_class() -> None:
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id
def test_environment_descriptor_returns_workspace_seed_from_runtime_state() -> None:
layer = _shell_layer(client=FakeShellctlClient())
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
descriptor = layer.environment_descriptor()
assert descriptor == ShellctlEnvironmentDescriptor(workspace_cwd="~/workspace/abc12ff", session_id="abc12ff")
def test_environment_descriptor_raises_without_session_identity() -> None:
layer = _shell_layer(client=FakeShellctlClient())
with pytest.raises(ValueError, match="session_id or workspace_cwd"):
_ = layer.environment_descriptor()
def test_shell_layer_create_provisions_workspace_and_bootstraps(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "deadbeefdeadbeef")
def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_workspace_collision(
monkeypatch: pytest.MonkeyPatch,
) -> None:
random_suffixes = iter(["aa", "bb"])
monkeypatch.setattr(time, "time", lambda: 0x12345F)
monkeypatch.setattr(secrets, "token_hex", lambda nbytes: next(random_suffixes))
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
assert cwd is None
assert env is None
if cwd is None:
assert 'mkdir -p "$HOME/workspace/deadbeefdeadbeef"' in script
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
raise AssertionError(f"Unexpected script with cwd={cwd}: {script}")
assert timeout == 30.0
if "2345faa" in script:
return _job_result("mkdir-collision", status=JobStatusName.EXITED, done=True, exit_code=17)
if "2345fbb" in script:
return _job_result("mkdir-success", status=JobStatusName.RUNNING, done=False, offset=4)
raise AssertionError(f"Unexpected script: {script}")
client = FakeShellctlClient(run_handler=run_handler)
layer = _shell_layer(client=client)
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
assert job_id == "mkdir-success"
assert offset == 4
assert timeout == 30.0
return _job_result("mkdir-success", status=JobStatusName.EXITED, done=True, exit_code=0, offset=8)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
@@ -286,25 +284,29 @@ def test_shell_layer_create_provisions_workspace_and_bootstraps(monkeypatch: pyt
asyncio.run(scenario())
assert layer.runtime_state.session_id == "deadbeefdeadbeef"
assert layer.runtime_state.workspace_cwd == "~/workspace/deadbeefdeadbeef"
assert layer.runtime_state.session_id == "2345fbb"
assert layer.runtime_state.workspace_cwd == "~/workspace/2345fbb"
assert layer.runtime_state.job_ids == ["mkdir-collision", "mkdir-success"]
assert layer.runtime_state.job_offsets == {"mkdir-collision": 0, "mkdir-success": 8}
assert 'mkdir "$HOME/workspace/2345fbb"' in client.run_calls[1].script
assert 'mkdir -p "$HOME/workspace/2345fbb"' not in client.run_calls[1].script
assert client.closed is True
def test_shell_layer_suspend_closes_client_before_resource_context_exits() -> None:
def test_shell_layer_suspend_leaves_client_open_until_resource_context_exits() -> None:
client = FakeShellctlClient()
layer = _shell_layer(client=client)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
async def scenario() -> None:
async with layer.resource_context():
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
await layer.on_context_suspend()
assert client.closed is True
assert client.closed is False
asyncio.run(scenario())
assert client.closed is True
def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None:
first_client = FakeShellctlClient(
@@ -315,31 +317,15 @@ def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None
exit_code=0,
)
)
second_client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(
"cleanup-job",
status=JobStatusName.EXITED,
done=True,
exit_code=0,
)
)
second_client = FakeShellctlClient()
created_entrypoints: list[str] = []
clients = iter([first_client, second_client])
def factory() -> FakeShellctlClient:
def factory(entrypoint: str) -> FakeShellctlClient:
created_entrypoints.append(entrypoint)
return next(clients)
provisioner = ShellctlProvisioner(client_factory=factory)
def make_provider(c: FakeShellctlClient) -> LayerProvider[DifyShellLayer]:
return LayerProvider.from_factory(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shell_provisioner=provisioner,
),
)
compositor = Compositor([LayerNode("shell", make_provider(first_client))])
compositor = Compositor([LayerNode("shell", _shell_provider(client_factory=factory))])
async def scenario() -> None:
async with compositor.enter(configs={"shell": DifyShellLayerConfig()}) as run:
@@ -367,44 +353,57 @@ def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None
assert second_client.closed is False
assert resumed_shell.runtime_state.session_id == initial_session_id
assert resumed_shell.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}"
assert set(resumed_shell.runtime_state.job_ids) == {"user-job"}
assert resumed_shell.runtime_state.job_offsets == {"user-job": 42}
assert set(resumed_shell.runtime_state.job_ids) == {"mkdir-job", "user-job"}
assert resumed_shell.runtime_state.job_offsets == {"mkdir-job": 0, "user-job": 42}
resumed_run.suspend_layer_on_exit("shell")
assert second_client.closed is True
asyncio.run(scenario())
assert created_entrypoints == ["http://shellctl", "http://shellctl"]
def test_shell_layer_delete_force_deletes_tracked_jobs_then_destroys_workspace() -> None:
def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_and_closes_client() -> None:
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
del cwd, env, timeout
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0)
assert script == 'rm -rf -- "$HOME/workspace/abc12ff"'
assert cwd is None
assert env is None
assert timeout == 30.0
return _job_result("cleanup-job", status=JobStatusName.RUNNING, done=False, offset=3)
client = FakeShellctlClient(run_handler=run_handler)
layer = _shell_layer(client=client)
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
assert job_id == "cleanup-job"
assert offset == 3
assert timeout == 30.0
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0, offset=5)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state.job_ids = ["user-job", "mkdir-job"]
layer.runtime_state.job_offsets = {"user-job": 9, "mkdir-job": 1}
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
await layer.on_context_delete()
assert client.closed is False
asyncio.run(scenario())
deleted_job_ids = {call.job_id for call in client.delete_calls}
assert {"user-job", "mkdir-job"}.issubset(deleted_job_ids)
assert client.events[:2] == [("run", 'rm -rf -- "$HOME/workspace/abc12ff"'), ("wait", "cleanup-job")]
assert {call.job_id for call in client.delete_calls} == {"user-job", "mkdir-job", "cleanup-job"}
assert all(
client.events.index(("delete", call.job_id)) > client.events.index(("wait", "cleanup-job"))
for call in client.delete_calls
)
assert all(call.force is True for call in client.delete_calls)
assert layer.runtime_state.job_ids == []
assert layer.runtime_state.job_offsets == {}
assert client.closed is True
def test_shell_layer_create_failure_destroys_provisioned_workspace() -> None:
def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(
"mkdir-failed",
@@ -413,26 +412,32 @@ def test_shell_layer_create_failure_destroys_provisioned_workspace() -> None:
exit_code=1,
)
)
layer = _shell_layer(client=client)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
with pytest.raises(ShellProvisionError, match="Failed to create shell workspace"):
with pytest.raises(RuntimeError, match="Failed to create shell workspace"):
async with layer.resource_context():
await layer.on_context_create()
asyncio.run(scenario())
assert [call.job_id for call in client.delete_calls] == ["mkdir-failed"]
assert all(call.force is True for call in client.delete_calls)
assert layer.runtime_state.job_ids == []
assert layer.runtime_state.job_offsets == {}
assert client.closed is True
def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff")
monkeypatch.setattr(time, "time", lambda: 0xABC12)
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "ff")
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
assert env is None
if cwd is None:
assert timeout == 30.0
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
assert cwd == "~/workspace/abc12ffabc12ff"
assert cwd == "~/workspace/abc12ff"
assert "export PROJECT_NAME='demo project'" in script
assert "export QUOTED='it'\\''s ok'" in script
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
@@ -445,7 +450,7 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
client = FakeShellctlClient(run_handler=run_handler)
layer = _shell_layer(
client=client,
client_factory=lambda _entrypoint: client,
config=DifyShellLayerConfig(
cli_tools=[
DifyShellCliToolConfig(
@@ -470,11 +475,17 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
asyncio.run(scenario())
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"]
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"]
def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff")
monkeypatch.setattr(time, "time", lambda: 0xABC12)
def token_hex(_nbytes: int) -> str:
return "ff"
monkeypatch.setattr(secrets, "token_hex", token_hex)
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
del timeout
@@ -482,7 +493,7 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
if cwd is None:
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
assert cwd == "~/workspace/abc12ffabc12ff"
assert cwd == "~/workspace/abc12ff"
assert "export PROJECT_NAME='demo project'" in script
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
@@ -492,7 +503,7 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
client = FakeShellctlClient(run_handler=run_handler)
layer = _shell_layer(
client=client,
client_factory=lambda _entrypoint: client,
config=DifyShellLayerConfig(
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")],
secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")],
@@ -515,7 +526,8 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
asyncio.run(scenario())
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"]
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
assert layer.runtime_state.job_ids == ["mkdir-job", "user-job"]
def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None:
@@ -575,15 +587,12 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
input_handler=input_handler,
terminate_handler=terminate_handler,
)
layer = _shell_layer(client=client)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
run_tool_def = await tools["shell_run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
wait_tool_def = await tools["shell_wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
@@ -633,7 +642,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
assert layer.runtime_state.job_ids == ["user-job"]
assert layer.runtime_state.job_offsets == {"user-job": 22}
assert client.closed is False
assert client.closed is True
def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None:
@@ -648,7 +657,8 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
client = FakeShellctlClient(run_handler=run_handler)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_api_base_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
@@ -710,14 +720,11 @@ def test_run_remote_script_uses_workspace_cwd_accumulates_output_and_deletes_job
)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
layer = _shell_layer(client=client)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
result = await layer.run_remote_script("printf 'hello world'", timeout=7.5)
assert result.output == "hello world"
assert result.exit_code == 0
@@ -746,14 +753,11 @@ def test_run_remote_script_deletes_job_even_when_command_exits_non_zero() -> Non
)
client = FakeShellctlClient(run_handler=run_handler)
layer = _shell_layer(client=client)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
result = await layer.run_remote_script("exit 17", timeout=3.0)
assert result.exit_code == 17
assert result.output == "failed\n"
@@ -781,7 +785,8 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
client = FakeShellctlClient(run_handler=run_handler)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_api_base_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
@@ -792,9 +797,6 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
_ = await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
asyncio.run(scenario())
@@ -813,7 +815,8 @@ def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_api_base_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
@@ -823,9 +826,6 @@ def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
with pytest.raises(RuntimeError, match="Agent Stub environment injection is not available"):
await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
@@ -845,7 +845,8 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
)
layer = DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_api_base_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
@@ -856,9 +857,6 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
_ = await tools["shell_run"].function_schema.call(
{"script": "pwd"},
None, # pyright: ignore[reportArgumentType]
@@ -871,15 +869,12 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None:
client = FakeShellctlClient()
layer = _shell_layer(client=client)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer._shell_handle = ShellctlHandle(
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
)
wait_result = await tools["shell_wait"].function_schema.call(
{"job_id": "missing-job"},
@@ -907,16 +902,19 @@ def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() ->
def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None:
client = FakeShellctlClient()
layer = _shell_layer(client=client)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
with pytest.raises(RuntimeError, match="resource_context"):
await layer.on_context_suspend()
run_result = await tools["shell_run"].function_schema.call(
{"script": "pwd"},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(run_result, includes="shell handle")
_assert_error_observation(run_result, includes="resource_context")
asyncio.run(scenario())
@@ -924,7 +922,7 @@ def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_contex
def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None:
with pytest.raises(ValueError, match="session_id must be 7 or 16 lowercase hex characters"):
with pytest.raises(ValueError, match="session_id must match"):
_ = DifyShellRuntimeState.model_validate(
{
"session_id": "../../tmp",
@@ -1,10 +1,6 @@
from typing import cast
import pytest
import dify_agent.runtime.compositor_factory as compositor_factory_module
from dify_agent.adapters.shell.config import ShellAdapterSettings
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
@@ -12,30 +8,30 @@ from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.runtime.compositor_factory import create_default_layer_providers
class FakeProvisioner:
"""No-op provisioner for tests that never actually provision a workspace."""
async def provision(self) -> object:
raise AssertionError("provision should not be called by these tests")
async def reattach(self, descriptor: object) -> object:
raise AssertionError("reattach should not be called by these tests")
async def destroy(self, handle: object) -> None:
raise AssertionError("destroy should not be called by these tests")
class FakeFactoryClient:
async def close(self) -> None:
return None
def test_default_layer_providers_register_shell_layer_with_configured_token_factory(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_settings: list[ShellAdapterSettings] = []
fake_provisioner = FakeProvisioner()
captured_tokens: list[str] = []
captured_entrypoints: list[str] = []
fake_client = FakeFactoryClient()
def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol:
captured_settings.append(settings)
return cast(ShellProvisionProtocol, fake_provisioner)
def fake_create_shellctl_client_factory(*, token: str):
captured_tokens.append(token)
monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner)
def factory(entrypoint: str) -> FakeFactoryClient:
captured_entrypoints.append(entrypoint)
return fake_client
return factory
monkeypatch.setattr(
compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory
)
providers = create_default_layer_providers(
shellctl_entrypoint="http://shellctl.example",
@@ -45,29 +41,34 @@ def test_default_layer_providers_register_shell_layer_with_configured_token_fact
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
assert isinstance(shell_layer, DifyShellLayer)
assert shell_layer.shell_provisioner is fake_provisioner
assert len(captured_settings) == 1
assert captured_settings[0].shellctl_entrypoint == "http://shellctl.example"
assert captured_settings[0].shellctl_auth_token == "shell-secret"
assert shell_layer.shellctl_entrypoint == "http://shellctl.example"
assert captured_tokens == ["shell-secret"]
assert shell_layer.shellctl_client_factory(shell_layer.shellctl_entrypoint) is fake_client
assert captured_entrypoints == ["http://shellctl.example"]
def test_default_layer_providers_keep_empty_shellctl_token_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_settings: list[ShellAdapterSettings] = []
captured_tokens: list[str] = []
def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol:
captured_settings.append(settings)
return cast(ShellProvisionProtocol, FakeProvisioner())
def fake_create_shellctl_client_factory(*, token: str):
captured_tokens.append(token)
monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner)
def factory(_entrypoint: str) -> FakeFactoryClient:
return FakeFactoryClient()
return factory
monkeypatch.setattr(
compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory
)
providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example")
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
_ = shell_provider.create_layer(DifyShellLayerConfig())
assert len(captured_settings) == 1
assert captured_settings[0].shellctl_auth_token is None
assert captured_tokens == [""]
def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_is_created() -> None:
@@ -29,7 +29,6 @@ from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.layers.dify_plugin.configs import (
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
@@ -1347,7 +1346,8 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: shell_client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: shell_client,
),
)
layer_providers = tuple(
@@ -2342,7 +2342,7 @@ def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None:
async def scenario() -> None:
async with httpx.AsyncClient() as client:
with pytest.raises(AgentRunValidationError, match="non-null shell provisioner"):
with pytest.raises(AgentRunValidationError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
await AgentRunRunner(
sink=sink,
request=request,
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import base64
import time
from typing import ClassVar
@@ -7,8 +8,7 @@ from typing import ClassVar
import httpx
import pytest
from fastapi.testclient import TestClient
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
from shell_session_manager.shellctl.client import ShellctlClient
import dify_agent.server.app as app_module
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
@@ -246,8 +246,12 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
assert isinstance(knowledge_layer, DifyKnowledgeBaseLayer)
assert knowledge_layer.inner_api_url == "http://dify-api"
assert knowledge_layer.inner_api_key == "inner-secret"
assert isinstance(shell_layer.shell_provisioner, ShellctlProvisioner)
assert shell_layer.shellctl_entrypoint == "http://shellctl"
assert shell_layer.agent_stub_api_base_url == "https://agent.example.com/agent-stub"
shellctl_client = shell_layer.shellctl_client_factory("http://shellctl")
assert isinstance(shellctl_client, ShellctlClient)
assert shellctl_client.token == "shell-secret"
asyncio.run(shellctl_client.close())
http_client = scheduler.plugin_daemon_http_client
assert http_client is fake_http_client
assert http_client.is_closed is False
@@ -19,7 +19,6 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.shell import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
from dify_agent.protocol import (
CreateRunRequest,
RunComposition,
@@ -39,7 +38,7 @@ from dify_agent.server.sandbox_files import (
)
from fastapi import FastAPI
from fastapi.testclient import TestClient
from shell_session_manager.shellctl.shared import JobResult, JobStatusName
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName
@dataclass(slots=True)
@@ -72,7 +71,7 @@ class FakeShellctlClient:
return self.run_handler(script, cwd, env, timeout)
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
return self.run_handler("", None, None, timeout)
raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}")
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}")
@@ -85,10 +84,11 @@ class FakeShellctlClient:
job_id: str,
*,
force: bool = False,
) -> object:
del force
grace_seconds: float | None = None,
) -> DeleteJobResponse:
del force, grace_seconds
self.delete_calls.append(job_id)
return None
return DeleteJobResponse(job_id=job_id)
async def close(self) -> None:
return None
@@ -191,7 +191,8 @@ def _service(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: client,
agent_stub_api_base_url="https://agent.example.com/agent-stub",
agent_stub_token_factory=lambda execution_context, *, session_id: (
f"token-for:{execution_context.tenant_id}:{session_id}"
-10
View File
@@ -294,13 +294,3 @@ Or browse the step definition files directly:
- `features/step-definitions/common/` — auth guards and navigation assertions shared by all features
- `features/step-definitions/<capability>/` — domain-specific steps scoped to a single feature area
## Agent v2 scenarios
Agent v2 scenarios live under `features/agent-v2/` and use the `@agent-v2` capability tag.
The E2E web environment enables Agent v2 through `NEXT_PUBLIC_ENABLE_AGENT_V2=true` in `scripts/common.ts`, because `/roster` routes are guarded by that feature flag.
Use `support/agent.ts` for Agent v2 API fixtures. It owns roster-shaped Agent IDs, configure/access route helpers, composer draft sync, build-draft helpers, publish, API access toggles, and Agent cleanup. Store created roster Agent IDs in `DifyWorld.createdAgentIds`; the shared `After` hook deletes them after each scenario.
Keep Agent v2 step definitions under `features/step-definitions/agent-v2/`. Prefer API setup for prerequisite state, then use Playwright only for user-observable navigation, editing, and assertions.
@@ -1,9 +0,0 @@
@agent-v2 @authenticated @infra
Feature: Agent v2 configure entry
Scenario: Open the configure page for an Agent v2 test agent
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And a minimal Agent v2 composer draft has been synced
When I open the Agent v2 configure page
Then I should be on the Agent v2 configure page
And I should see the Agent v2 configure workspace
@@ -1,49 +0,0 @@
import type { DifyWorld } from '../../support/world'
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import {
createTestAgent,
getAgentConfigurePath,
saveAgentComposerDraft,
} from '../../../support/agent'
Given('an Agent v2 test agent has been created via API', async function (this: DifyWorld) {
const agent = await createTestAgent()
this.createdAgentIds.push(agent.id)
this.lastCreatedAgentName = agent.name
this.lastCreatedAgentRole = agent.role
})
Given('a minimal Agent v2 composer draft has been synced', async function (this: DifyWorld) {
const agentId = this.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
await saveAgentComposerDraft(agentId)
})
When('I open the Agent v2 configure page', async function (this: DifyWorld) {
const agentId = this.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
await this.getPage().goto(getAgentConfigurePath(agentId))
})
Then('I should be on the Agent v2 configure page', async function (this: DifyWorld) {
const agentId = this.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
await expect(this.getPage()).toHaveURL(
new RegExp(`/roster/agent/${agentId}/configure(?:\\?.*)?$`),
)
})
Then('I should see the Agent v2 configure workspace', async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByRole('region', { name: 'Configure' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible()
await expect(page.getByText(this.lastCreatedAgentName!)).toBeVisible()
})
+3 -6
View File
@@ -1,5 +1,4 @@
import type { Browser } from '@playwright/test'
import type { Buffer } from 'node:buffer'
import type { DifyWorld } from './world'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
@@ -7,7 +6,6 @@ import { fileURLToPath } from 'node:url'
import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber'
import { chromium } from '@playwright/test'
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
import { deleteTestAgent } from '../../support/agent'
import { deleteTestApp } from '../../support/api'
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
@@ -43,7 +41,7 @@ BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => {
slowMo: cucumberSlowMo,
})
console.warn(`[e2e] session cache bootstrap against ${baseURL}`)
console.log(`[e2e] session cache bootstrap against ${baseURL}`)
await ensureAuthenticatedState(browser, baseURL)
})
@@ -60,7 +58,7 @@ Before(async function (this: DifyWorld, { pickle }) {
this.scenarioStartedAt = Date.now()
const tags = pickle.tags.map(tag => tag.name).join(' ')
console.warn(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
console.log(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
})
After(async function (this: DifyWorld, { pickle, result }) {
@@ -87,11 +85,10 @@ After(async function (this: DifyWorld, { pickle, result }) {
}
const status = result?.status || 'UNKNOWN'
console.warn(
console.log(
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
)
for (const id of this.createdAgentIds) await deleteTestAgent(id).catch(() => {})
for (const id of this.createdAppIds) await deleteTestApp(id).catch(() => {})
await this.closeSession()
-6
View File
@@ -13,10 +13,7 @@ export class DifyWorld extends World {
scenarioStartedAt: number | undefined
session: AuthSessionMetadata | undefined
lastCreatedAppName: string | undefined
lastCreatedAgentName: string | undefined
lastCreatedAgentRole: string | undefined
createdAppIds: string[] = []
createdAgentIds: string[] = []
capturedDownloads: Download[] = []
shareURL: string | undefined
@@ -29,10 +26,7 @@ export class DifyWorld extends World {
this.consoleErrors = []
this.pageErrors = []
this.lastCreatedAppName = undefined
this.lastCreatedAgentName = undefined
this.lastCreatedAgentRole = undefined
this.createdAppIds = []
this.createdAgentIds = []
this.capturedDownloads = []
this.shareURL = undefined
}
-2
View File
@@ -1,4 +1,3 @@
import type { Buffer } from 'node:buffer'
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
@@ -43,7 +42,6 @@ export const webEnvExampleFile = path.join(webDir, '.env.example')
export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example')
export const e2eWebEnvOverrides = {
NEXT_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/console/api',
NEXT_PUBLIC_ENABLE_AGENT_V2: 'true',
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/api',
} satisfies Record<string, string>
-229
View File
@@ -1,229 +0,0 @@
import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from './api'
export type AgentSeed = {
app_id?: string
backing_app_id?: string
description?: string
enable_site?: boolean
id: string
name: string
role?: string
site?: {
access_token?: string | null
app_base_url?: string | null
code?: string | null
} | null
}
export type AgentSoulConfig = Record<string, unknown>
export type AgentComposerResponse = {
agent_soul?: AgentSoulConfig
}
export type AgentBuildDraftResponse = {
agent_soul: AgentSoulConfig
draft: Record<string, unknown>
variant: 'agent_app'
}
export type AgentApiAccess = {
api_key_count: number
api_reference_url: string
endpoint: string
enabled: boolean
files_upload_endpoint: string
}
export type AgentApiKey = {
id: string
token?: string
}
export const defaultAgentSoulConfig: AgentSoulConfig = {
prompt: {
system_prompt: 'You are a Dify Agent E2E test assistant.',
},
}
export const getAgentConfigurePath = (agentId: string) => `/roster/agent/${agentId}/configure`
export const getAgentAccessPath = (agentId: string) => `/roster/agent/${agentId}/access`
export async function createTestAgent({
description = 'Created by Dify E2E.',
name = `E2E Agent ${Date.now()}`,
role = 'E2E test assistant',
}: {
description?: string
name?: string
role?: string
} = {}): Promise<AgentSeed> {
const ctx = await createApiContext()
try {
const response = await ctx.post('/console/api/agent', {
data: {
description,
icon: '🤖',
icon_background: '#FFEAD5',
icon_type: 'emoji',
name,
role,
},
})
await expectApiResponseOK(response, 'Create Agent v2 test agent')
return (await response.json()) as AgentSeed
}
finally {
await ctx.dispose()
}
}
export async function getTestAgent(agentId: string): Promise<AgentSeed> {
const ctx = await createApiContext()
try {
const response = await ctx.get(`/console/api/agent/${agentId}`)
await expectApiResponseOK(response, `Get Agent v2 test agent ${agentId}`)
return (await response.json()) as AgentSeed
}
finally {
await ctx.dispose()
}
}
export async function deleteTestAgent(agentId: string): Promise<void> {
const ctx = await createApiContext()
try {
const response = await ctx.delete(`/console/api/agent/${agentId}`)
await expectApiResponseOK(response, `Delete Agent v2 test agent ${agentId}`)
}
finally {
await ctx.dispose()
}
}
export async function saveAgentComposerDraft(
agentId: string,
agentSoul: AgentSoulConfig = defaultAgentSoulConfig,
): Promise<AgentComposerResponse> {
const ctx = await createApiContext()
try {
const response = await ctx.put(`/console/api/agent/${agentId}/composer`, {
data: {
agent_soul: agentSoul,
save_strategy: 'save_to_current_version',
variant: 'agent_app',
},
})
await expectApiResponseOK(response, `Save Agent v2 composer draft for ${agentId}`)
return (await response.json()) as AgentComposerResponse
}
finally {
await ctx.dispose()
}
}
export async function checkoutAgentBuildDraft(agentId: string): Promise<AgentBuildDraftResponse> {
const ctx = await createApiContext()
try {
const response = await ctx.post(`/console/api/agent/${agentId}/build-draft/checkout`, {
data: { force: true },
})
await expectApiResponseOK(response, `Checkout Agent v2 build draft for ${agentId}`)
return (await response.json()) as AgentBuildDraftResponse
}
finally {
await ctx.dispose()
}
}
export async function discardAgentBuildDraft(agentId: string): Promise<void> {
const ctx = await createApiContext()
try {
const response = await ctx.delete(`/console/api/agent/${agentId}/build-draft`)
await expectApiResponseOK(response, `Discard Agent v2 build draft for ${agentId}`)
}
finally {
await ctx.dispose()
}
}
export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise<void> {
const ctx = await createApiContext()
try {
const response = await ctx.post(`/console/api/agent/${agentId}/publish`, {
data: { version_note: versionNote },
})
await expectApiResponseOK(response, `Publish Agent v2 test agent ${agentId}`)
}
finally {
await ctx.dispose()
}
}
export async function enableAgentSiteAndGetURL(agentId: string): Promise<string> {
const agent = await getTestAgent(agentId)
const appId = agent.app_id ?? agent.backing_app_id
if (!appId)
throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`)
const appDetail = await setAppSiteEnabled(appId, true)
const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site.access_token
const baseURL = agent.site?.app_base_url ?? appDetail.site.app_base_url
return `${baseURL.replace(/\/$/, '')}/agent/${token}`
}
export async function getAgentApiAccess(agentId: string): Promise<AgentApiAccess> {
const ctx = await createApiContext()
try {
const response = await ctx.get(`/console/api/agent/${agentId}/api-access`)
await expectApiResponseOK(response, `Get Agent v2 API access for ${agentId}`)
return (await response.json()) as AgentApiAccess
}
finally {
await ctx.dispose()
}
}
export async function setAgentApiAccess(
agentId: string,
enabled: boolean,
): Promise<AgentApiAccess> {
const ctx = await createApiContext()
try {
const response = await ctx.post(`/console/api/agent/${agentId}/api-enable`, {
data: { enable_api: enabled },
})
await expectApiResponseOK(
response,
`${enabled ? 'Enable' : 'Disable'} Agent v2 API access for ${agentId}`,
)
return (await response.json()) as AgentApiAccess
}
finally {
await ctx.dispose()
}
}
export async function createAgentApiKey(agentId: string): Promise<AgentApiKey> {
const ctx = await createApiContext()
try {
const response = await ctx.post(`/console/api/agent/${agentId}/api-keys`)
await expectApiResponseOK(response, `Create Agent v2 API key for ${agentId}`)
return (await response.json()) as AgentApiKey
}
finally {
await ctx.dispose()
}
}
export async function deleteAgentApiKey(agentId: string, apiKeyId: string): Promise<void> {
const ctx = await createApiContext()
try {
const response = await ctx.delete(`/console/api/agent/${agentId}/api-keys/${apiKeyId}`)
await expectApiResponseOK(response, `Delete Agent v2 API key ${apiKeyId} for ${agentId}`)
}
finally {
await ctx.dispose()
}
}
+8 -31
View File
@@ -1,4 +1,3 @@
import type { APIResponse } from '@playwright/test'
import { readFile } from 'node:fs/promises'
import { request } from '@playwright/test'
import { authStatePath } from '../fixtures/auth'
@@ -8,7 +7,7 @@ type StorageState = {
cookies: Array<{ name: string, value: string }>
}
export async function createApiContext() {
async function createApiContext() {
const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState
const csrfToken = state.cookies.find(c => c.name.endsWith('csrf_token'))?.value ?? ''
@@ -19,14 +18,6 @@ export async function createApiContext() {
})
}
export async function expectApiResponseOK(response: APIResponse, action: string): Promise<void> {
if (response.ok())
return
const body = await response.text().catch(() => '')
throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`)
}
export type AppSeed = {
id: string
name: string
@@ -150,34 +141,20 @@ export async function publishWorkflowApp(appId: string): Promise<void> {
}
}
export type AppDetailWithSite = {
mode?: string
type AppDetailWithSite = {
site: { access_token: string, app_base_url: string, enable_site: boolean }
}
export function getAppSiteURL({ mode, site }: AppDetailWithSite): string {
const webAppMode = mode === 'completion' || mode === 'workflow' ? mode : 'chat'
return `${site.app_base_url}/${webAppMode}/${site.access_token}`
}
export async function enableAppSiteAndGetURL(appId: string): Promise<string> {
return getAppSiteURL(await setAppSiteEnabled(appId, true))
}
export async function setAppSiteEnabled(
appId: string,
enabled: boolean,
): Promise<AppDetailWithSite> {
const ctx = await createApiContext()
try {
const enableResponse = await ctx.post(`/console/api/apps/${appId}/site-enable`, {
data: { enable_site: enabled },
await ctx.post(`/console/api/apps/${appId}/site-enable`, {
data: { enable_site: true },
})
await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`)
const detailResponse = await ctx.get(`/console/api/apps/${appId}`)
await expectApiResponseOK(detailResponse, `Get app site detail for ${appId}`)
return (await detailResponse.json()) as AppDetailWithSite
const res = await ctx.get(`/console/api/apps/${appId}`)
const body = (await res.json()) as AppDetailWithSite
const { app_base_url, access_token } = body.site
return `${app_base_url}/workflow/${access_token}`
}
finally {
await ctx.dispose()
+11 -13
View File
@@ -122,23 +122,21 @@ const waitForProcessExit = (childProcess: ChildProcess, timeoutMs: number) =>
return
}
let timeout: ReturnType<typeof setTimeout>
function cleanup() {
clearTimeout(timeout)
childProcess.off('exit', onExit)
}
function onExit() {
cleanup()
resolve()
}
timeout = setTimeout(() => {
const timeout = setTimeout(() => {
cleanup()
resolve()
}, timeoutMs)
const onExit = () => {
cleanup()
resolve()
}
const cleanup = () => {
clearTimeout(timeout)
childProcess.off('exit', onExit)
}
childProcess.once('exit', onExit)
})
+34 -3
View File
@@ -1,4 +1,22 @@
{
"e2e/features/support/hooks.ts": {
"no-console": {
"count": 3
},
"node/prefer-global/buffer": {
"count": 1
}
},
"e2e/scripts/common.ts": {
"node/prefer-global/buffer": {
"count": 2
}
},
"e2e/support/process.ts": {
"ts/no-use-before-define": {
"count": 2
}
},
"packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts": {
"no-console": {
"count": 11
@@ -1919,9 +1937,6 @@
"web/app/components/base/markdown-blocks/form.tsx": {
"no-restricted-imports": {
"count": 1
},
"prefer-regex-literals": {
"count": 1
}
},
"web/app/components/base/markdown-blocks/index.ts": {
@@ -4347,6 +4362,14 @@
"count": 1
}
},
"web/app/components/snippet-list/components/snippet-card.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/snippets/components/snippet-run-panel.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 5
@@ -4363,6 +4386,11 @@
"count": 1
}
},
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/snippets/hooks/use-snippet-run.ts": {
"no-restricted-imports": {
"count": 2
@@ -6284,6 +6312,9 @@
},
"ts/no-explicit-any": {
"count": 5
},
"unicorn/prefer-number-properties": {
"count": 2
}
},
"web/app/components/workflow/panel/chat-variable-panel/components/object-value-list.tsx": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "dify",
"type": "module",
"private": true,
"packageManager": "pnpm@11.9.0",
"packageManager": "pnpm@11.8.0",
"devEngines": {
"runtime": {
"name": "node",
@@ -43,9 +43,6 @@ import {
zGetSnippetsBySnippetIdWorkflowsPublishResponse,
zGetSnippetsBySnippetIdWorkflowsQuery,
zGetSnippetsBySnippetIdWorkflowsResponse,
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody,
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath,
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse,
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody,
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath,
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse,
@@ -713,31 +710,7 @@ export const restore = {
post: post8,
}
/**
* Update a published snippet workflow version's display metadata
*
* Update published snippet workflow attributes
*/
export const patch2 = oc
.route({
description: 'Update published snippet workflow attributes',
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchSnippetsBySnippetIdWorkflowsByWorkflowId',
path: '/snippets/{snippet_id}/workflows/{workflow_id}',
summary: 'Update a published snippet workflow version\'s display metadata',
tags: ['console'],
})
.input(
z.object({
body: zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody,
params: zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath,
}),
)
.output(zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse)
export const byWorkflowId = {
patch: patch2,
restore,
}
@@ -208,11 +208,6 @@ export type WorkflowPublishResponse = {
result: string
}
export type WorkflowUpdatePayload = {
marked_comment?: string | null
marked_name?: string | null
}
export type WorkflowRunForListResponse = {
created_at?: number | null
created_by_account?: SimpleAccount | null
@@ -828,28 +823,6 @@ export type PostSnippetsBySnippetIdWorkflowsPublishResponses = {
export type PostSnippetsBySnippetIdWorkflowsPublishResponse
= PostSnippetsBySnippetIdWorkflowsPublishResponses[keyof PostSnippetsBySnippetIdWorkflowsPublishResponses]
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdData = {
body: WorkflowUpdatePayload
path: {
snippet_id: string
workflow_id: string
}
query?: never
url: '/snippets/{snippet_id}/workflows/{workflow_id}'
}
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdErrors = {
400: unknown
404: unknown
}
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses = {
200: SnippetWorkflowResponse
}
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse
= PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses]
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreData = {
body?: never
path: {
@@ -133,14 +133,6 @@ export const zWorkflowPublishResponse = z.object({
result: z.string(),
})
/**
* WorkflowUpdatePayload
*/
export const zWorkflowUpdatePayload = z.object({
marked_comment: z.string().max(100).nullish(),
marked_name: z.string().max(20).nullish(),
})
/**
* SimpleAccount
*/
@@ -675,18 +667,6 @@ export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({
*/
export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = zWorkflowPublishResponse
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath = z.object({
snippet_id: z.uuid(),
workflow_id: z.string(),
})
/**
* Workflow updated successfully
*/
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse = zSnippetWorkflowResponse
export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object({
snippet_id: z.uuid(),
workflow_id: z.string(),
@@ -3,36 +3,12 @@
import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zPostWorkflowGenerateBody,
zPostWorkflowGenerateResponse,
zPostWorkflowGenerateSuggestionsBody,
zPostWorkflowGenerateSuggestionsResponse,
} from './zod.gen'
/**
* Suggest example workflow-generator instructions for the tenant
*/
export const post = oc
.route({
description: 'Suggest example workflow-generator instructions for the tenant',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkflowGenerateSuggestions',
path: '/workflow-generate/suggestions',
tags: ['console'],
})
.input(z.object({ body: zPostWorkflowGenerateSuggestionsBody }))
.output(zPostWorkflowGenerateSuggestionsResponse)
export const suggestions = {
post,
}
import { zPostWorkflowGenerateBody, zPostWorkflowGenerateResponse } from './zod.gen'
/**
* Generate a Dify workflow graph from natural language
*/
export const post2 = oc
export const post = oc
.route({
description: 'Generate a Dify workflow graph from natural language',
inputStructure: 'detailed',
@@ -45,8 +21,7 @@ export const post2 = oc
.output(zPostWorkflowGenerateResponse)
export const workflowGenerate = {
post: post2,
suggestions,
post,
}
export const contract = {
@@ -10,18 +10,12 @@ export type WorkflowGeneratePayload = {
} | null
ideal_output?: string
instruction: string
mode: 'advanced-chat' | 'auto' | 'workflow'
mode: 'advanced-chat' | 'workflow'
model_config: ModelConfig
}
export type GeneratorResponse = unknown
export type WorkflowInstructionSuggestionsPayload = {
count?: number
language?: string | null
mode: 'advanced-chat' | 'workflow'
}
export type ModelConfig = {
completion_params?: {
[key: string]: unknown
@@ -51,21 +45,3 @@ export type PostWorkflowGenerateResponses = {
export type PostWorkflowGenerateResponse
= PostWorkflowGenerateResponses[keyof PostWorkflowGenerateResponses]
export type PostWorkflowGenerateSuggestionsData = {
body: WorkflowInstructionSuggestionsPayload
path?: never
query?: never
url: '/workflow-generate/suggestions'
}
export type PostWorkflowGenerateSuggestionsErrors = {
400: unknown
}
export type PostWorkflowGenerateSuggestionsResponses = {
200: GeneratorResponse
}
export type PostWorkflowGenerateSuggestionsResponse
= PostWorkflowGenerateSuggestionsResponses[keyof PostWorkflowGenerateSuggestionsResponses]
@@ -7,21 +7,6 @@ import * as z from 'zod'
*/
export const zGeneratorResponse = z.unknown()
/**
* WorkflowInstructionSuggestionsPayload
*
* Payload for the workflow-generator instruction-suggestions endpoint.
*
* Runs before the user picks a model, so the suggestions come from the
* tenant's default model. The underlying generator never raises an empty
* ``suggestions`` list is a valid 200 (soft-fail).
*/
export const zWorkflowInstructionSuggestionsPayload = z.object({
count: z.int().gte(1).lte(6).optional().default(4),
language: z.string().nullish(),
mode: z.enum(['advanced-chat', 'workflow']),
})
/**
* LLMMode
*
@@ -52,7 +37,7 @@ export const zWorkflowGeneratePayload = z.object({
current_graph: z.record(z.string(), z.unknown()).nullish(),
ideal_output: z.string().optional().default(''),
instruction: z.string(),
mode: z.enum(['advanced-chat', 'auto', 'workflow']),
mode: z.enum(['advanced-chat', 'workflow']),
model_config: zModelConfig,
})
@@ -62,10 +47,3 @@ export const zPostWorkflowGenerateBody = zWorkflowGeneratePayload
* Workflow graph generated successfully
*/
export const zPostWorkflowGenerateResponse = zGeneratorResponse
export const zPostWorkflowGenerateSuggestionsBody = zWorkflowInstructionSuggestionsPayload
/**
* Suggestions generated successfully
*/
export const zPostWorkflowGenerateSuggestionsResponse = zGeneratorResponse
+190 -165
View File
@@ -17,7 +17,7 @@ const meta = {
layout: 'padded',
docs: {
description: {
component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens and surface treatments. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.',
component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens, panel surfaces, and scrollbar spacing. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.',
},
},
},
@@ -27,7 +27,27 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
const scrollFadeRootClassName = cn(
'has-[>_:first-child:focus-visible]:outline-2',
'has-[>_:first-child:focus-visible]:outline-offset-0',
'has-[>_:first-child:focus-visible]:outline-state-accent-solid',
)
const rootClassName = 'relative min-h-0 min-w-0'
const viewportClassName = 'h-full max-h-full max-w-full rounded-xl border border-divider-subtle bg-components-panel-bg'
const fadeViewportClassName = cn(
'h-full max-h-full max-w-full rounded-xl bg-components-panel-bg outline-none focus-visible:outline-none',
'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
)
const scrollbarClassName = cn(
'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1',
'data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-1',
)
const verticalContentClassName = 'w-full max-w-full min-w-0'
const verticalContentStyle = { minWidth: 0 } satisfies React.CSSProperties
const panelClassName = 'min-w-0 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'
const pageClassName = 'min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5'
const labelClassName = 'system-xs-medium-uppercase text-text-tertiary'
const headingClassName = 'system-md-semibold text-text-primary'
const appRows = [
{ name: 'Invoice Copilot', meta: 'Pinned', icon: 'i-ri-file-list-3-line', selected: true, pinned: true },
@@ -79,10 +99,10 @@ function StorySection({
className?: string
}) {
return (
<section className={cn('min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5', className)}>
<section className={cn(pageClassName, className)}>
<div className="space-y-1">
<div className="system-xs-medium-uppercase text-text-tertiary">{eyebrow}</div>
<h3 className="system-md-semibold text-text-primary">{title}</h3>
<div className={labelClassName}>{eyebrow}</div>
<h3 className={headingClassName}>{title}</h3>
<p className="max-w-[72ch] text-pretty system-sm-regular text-text-secondary">{description}</p>
</div>
<div className="mt-5 flex justify-center">
@@ -102,7 +122,7 @@ function VerticalContent({
return (
<ScrollAreaContent
style={verticalContentStyle}
className={cn('w-full max-w-full min-w-0', className)}
className={cn(verticalContentClassName, className)}
>
{children}
</ScrollAreaContent>
@@ -116,20 +136,22 @@ export const Anatomy: Story = {
title="Base UI compound parts"
description="The baseline story mirrors the official Scroll Area anatomy: Root, Viewport, Content, Scrollbar, and Thumb, with keyboard focus drawn by the viewport."
>
<ScrollAreaRoot className="relative h-75 w-full max-w-105 min-w-0">
<ScrollAreaViewport aria-label="Scrollable anatomy example" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
<VerticalContent className="flex flex-col gap-4 py-2 pl-3 pr-5 text-text-secondary system-sm-regular leading-6">
{articleParagraphs.map(paragraph => (
<p key={paragraph}>
{paragraph}
</p>
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<div className={cn(panelClassName, 'h-75 w-full max-w-105')}>
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
<ScrollAreaViewport aria-label="Scrollable anatomy example" role="region" className={viewportClassName}>
<VerticalContent className="flex flex-col gap-4 py-2 pl-3 pr-5 text-text-secondary system-sm-regular leading-6">
{articleParagraphs.map(paragraph => (
<p key={paragraph}>
{paragraph}
</p>
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={scrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</StorySection>
),
}
@@ -141,26 +163,28 @@ export const Vertical: Story = {
title="Long form content"
description="Vertical overflow keeps the official viewport focus pattern while constraining content width so text never leaks outside the frame."
>
<ScrollAreaRoot className="relative h-90 w-full max-w-130 min-w-0">
<ScrollAreaViewport aria-label="Long form content" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
<VerticalContent className="flex flex-col gap-4 p-4 pr-6 text-text-secondary system-sm-regular leading-6">
<div className="space-y-1">
<div className="system-xs-medium-uppercase text-text-tertiary">Article</div>
<div className="system-md-semibold text-text-primary">Scrollable text region</div>
</div>
{Array.from({ length: 4 }, (_, groupIndex) => (
articleParagraphs.map(paragraph => (
<p key={`${groupIndex}-${paragraph}`}>
{paragraph}
</p>
))
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<div className={cn(panelClassName, 'h-90 w-full max-w-130')}>
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
<ScrollAreaViewport aria-label="Long form content" role="region" className={viewportClassName}>
<VerticalContent className="flex flex-col gap-4 p-4 pr-6 text-text-secondary system-sm-regular leading-6">
<div className="space-y-1">
<div className={labelClassName}>Article</div>
<div className={headingClassName}>Scrollable text region</div>
</div>
{Array.from({ length: 4 }, (_, groupIndex) => (
articleParagraphs.map(paragraph => (
<p key={`${groupIndex}-${paragraph}`}>
{paragraph}
</p>
))
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={scrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</StorySection>
),
}
@@ -172,26 +196,28 @@ export const VerticalTruncation: Story = {
title="Constrained content width"
description="Use width constraints plus minWidth: 0 on ScrollArea.Content when a vertical-only list should keep vertical scrolling while truncating long labels instead of creating horizontal scroll."
>
<ScrollAreaRoot className="relative h-48 w-full max-w-80 min-w-0">
<ScrollAreaViewport aria-label="Vertical file list" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
<VerticalContent className="flex flex-col gap-0.5 p-2">
{fileRows.map(file => (
<div
key={file}
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-text-secondary hover:bg-state-base-hover"
>
<span aria-hidden className="i-ri-file-text-line size-4 shrink-0" />
<span className="min-w-0 truncate system-sm-regular" title={file}>
{file}
</span>
</div>
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<div className={cn(panelClassName, 'h-48 w-full max-w-80')}>
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
<ScrollAreaViewport aria-label="Vertical file list" role="region" className={viewportClassName}>
<VerticalContent className="flex flex-col gap-0.5 p-2">
{fileRows.map(file => (
<div
key={file}
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-text-secondary hover:bg-state-base-hover"
>
<span aria-hidden className="i-ri-file-text-line size-4 shrink-0" />
<span className="min-w-0 truncate system-sm-regular" title={file}>
{file}
</span>
</div>
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={scrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</StorySection>
),
}
@@ -203,33 +229,24 @@ export const ScrollFade: Story = {
title="Viewport mask with root focus"
description="This mirrors the Base UI scroll-fade example: the viewport owns the mask and the root owns the focus outline so the indicator is never clipped."
>
<ScrollAreaRoot className={cn(
'relative h-90 w-full max-w-130 min-w-0',
'has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid',
)}
>
<ScrollAreaViewport
aria-label="Scroll fade article"
role="region"
className={cn(
'h-full max-h-full max-w-full rounded-xl bg-components-panel-bg outline-none focus-visible:outline-none',
'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
)}
>
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 text-text-secondary system-sm-regular leading-6">
{Array.from({ length: 5 }, (_, groupIndex) => (
articleParagraphs.map(paragraph => (
<p key={`${groupIndex}-${paragraph}`}>
{paragraph}
</p>
))
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className="opacity-0 data-hovering:opacity-100 data-scrolling:opacity-100 data-scrolling:duration-0">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<div className={cn(panelClassName, 'h-90 w-full max-w-130')}>
<ScrollAreaRoot className={cn(rootClassName, scrollFadeRootClassName, 'h-full p-1')}>
<ScrollAreaViewport aria-label="Scroll fade article" role="region" className={fadeViewportClassName}>
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 text-text-secondary system-sm-regular leading-6">
{Array.from({ length: 5 }, (_, groupIndex) => (
articleParagraphs.map(paragraph => (
<p key={`${groupIndex}-${paragraph}`}>
{paragraph}
</p>
))
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={scrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</StorySection>
),
}
@@ -242,22 +259,24 @@ export const Horizontal: Story = {
description="Horizontal overflow keeps Base UI's content sizing behavior and uses the same viewport focus treatment on the scrollable element."
className="mx-auto max-w-190"
>
<ScrollAreaRoot className="relative h-46 w-full max-w-130 min-w-0">
<ScrollAreaViewport aria-label="Horizontal numbered row" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
<ScrollAreaContent className="min-h-full min-w-max p-4 pb-6">
<div className="grid grid-cols-[repeat(18,6.25rem)] gap-3">
{gridCells.slice(0, 18).map(cell => (
<div key={cell} className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
{cell}
</div>
))}
</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="horizontal">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<div className={cn(panelClassName, 'h-46 w-full max-w-130')}>
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
<ScrollAreaViewport aria-label="Horizontal numbered row" role="region" className={viewportClassName}>
<ScrollAreaContent className="min-h-full min-w-max p-4 pb-6">
<div className="grid grid-cols-[repeat(18,6.25rem)] gap-3">
{gridCells.slice(0, 18).map(cell => (
<div key={cell} className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
{cell}
</div>
))}
</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="horizontal" className={scrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</StorySection>
),
}
@@ -269,26 +288,28 @@ export const BothAxes: Story = {
title="Numbered grid"
description="This follows the official two-axis example: both scrollbars are rendered and Corner reserves the intersection."
>
<ScrollAreaRoot className="relative h-85 w-full max-w-140 min-w-0">
<ScrollAreaViewport aria-label="Numbered grid" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
<ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3">
<div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3">
{gridCells.map(cell => (
<div key={cell} className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
{cell}
</div>
))}
</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar orientation="horizontal">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
<div className={cn(panelClassName, 'h-85 w-full max-w-140')}>
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
<ScrollAreaViewport aria-label="Numbered grid" role="region" className={viewportClassName}>
<ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3">
<div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3">
{gridCells.map(cell => (
<div key={cell} className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
{cell}
</div>
))}
</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className={scrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar orientation="horizontal" className={scrollbarClassName}>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
</div>
</StorySection>
),
}
@@ -303,50 +324,54 @@ export const AppSidebar: Story = {
title="Main navigation list"
description="A Dify-like sidebar keeps business UI outside the primitive while preserving the same Root, Viewport, Content, Scrollbar anatomy."
>
<div className="w-full max-w-70 rounded-xl bg-background-default-subtle p-3">
<div className="mb-4 flex h-8 items-center gap-2 rounded-lg bg-state-base-active px-2 text-text-accent">
<span className="i-ri-apps-fill size-4 shrink-0" aria-hidden />
<span className="min-w-0 truncate system-sm-semibold">Explore</span>
<div className="w-full max-w-70 rounded-2xl border border-divider-subtle bg-background-body p-3 shadow-lg shadow-shadow-shadow-5">
<div className="rounded-xl bg-background-default-subtle p-3">
<div className="mb-4 flex h-8 items-center gap-2 rounded-lg bg-state-base-active px-2 text-text-accent">
<span className="i-ri-apps-fill size-4 shrink-0" aria-hidden />
<span className="min-w-0 truncate system-sm-semibold">Explore</span>
</div>
<div className="mb-1.5 flex items-center justify-between px-2">
<span className={labelClassName}>Web apps</span>
<span className="system-xs-medium text-text-quaternary">{appRows.length}</span>
</div>
<div className="h-76 min-h-0">
<ScrollAreaRoot className={cn(rootClassName, 'h-full')}>
<ScrollAreaViewport aria-label="Web apps" role="region" className="h-full max-h-full max-w-full rounded-lg bg-transparent">
<VerticalContent className="space-y-0.5">
{appRows.map((row, index) => (
<div key={row.name} className="space-y-0.5">
<button
type="button"
className={cn(
'flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 text-left transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-state-accent-solid',
row.selected
? 'bg-state-base-active text-components-menu-item-text-active'
: 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-components-avatar-shape-fill-stop-100">
<span aria-hidden className={cn(row.icon, 'size-3.5')} />
</span>
<span className="min-w-0 truncate system-sm-regular">{row.name}</span>
</span>
<span className="shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 system-2xs-medium-uppercase text-text-quaternary">
{row.meta}
</span>
</button>
{index === pinnedCount - 1 && index !== appRows.length - 1 && (
<div className="my-1 h-px bg-divider-subtle" />
)}
</div>
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</div>
<div className="mb-1.5 flex items-center justify-between px-2">
<span className="system-xs-medium-uppercase text-text-tertiary">Web apps</span>
<span className="system-xs-medium text-text-quaternary">{appRows.length}</span>
</div>
<ScrollAreaRoot className="relative h-76 min-w-0">
<ScrollAreaViewport aria-label="Web apps" role="region" className="h-full max-h-full max-w-full rounded-lg bg-transparent">
<VerticalContent className="space-y-0.5 pr-3">
{appRows.map((row, index) => (
<div key={row.name} className="space-y-0.5">
<button
type="button"
className={cn(
'flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 text-left transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-state-accent-solid',
row.selected
? 'bg-state-base-active text-components-menu-item-text-active'
: 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
)}
>
<span className="flex min-w-0 items-center gap-2">
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-components-avatar-shape-fill-stop-100">
<span aria-hidden className={cn(row.icon, 'size-3.5')} />
</span>
<span className="min-w-0 truncate system-sm-regular">{row.name}</span>
</span>
<span className="shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 system-2xs-medium-uppercase text-text-quaternary">
{row.meta}
</span>
</button>
{index === pinnedCount - 1 && index !== appRows.length - 1 && (
<div className="my-1 h-px bg-divider-subtle" />
)}
</div>
))}
</VerticalContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
</div>
</StorySection>
)
+2623 -3258
View File
File diff suppressed because it is too large Load Diff
+43 -43
View File
@@ -51,30 +51,30 @@ overrides:
yaml@>=2.0.0 <2.8.3: 2.9.0
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.44.3
'@amplitude/plugin-session-replay-browser': 1.32.3
'@antfu/eslint-config': 9.1.0
'@amplitude/analytics-browser': 2.44.1
'@amplitude/plugin-session-replay-browser': 1.32.1
'@antfu/eslint-config': 9.0.0
'@base-ui/react': 1.6.0
'@chromatic-com/storybook': 5.2.1
'@cucumber/cucumber': 13.0.0
'@egoist/tailwindcss-icons': 1.9.2
'@emoji-mart/data': 1.2.1
'@eslint-react/eslint-plugin': 5.9.5
'@eslint-react/eslint-plugin': 5.9.0
'@eslint/js': 10.0.1
'@floating-ui/react': 0.27.19
'@formatjs/intl-localematcher': 0.8.10
'@heroicons/react': 2.2.0
'@hey-api/openapi-ts': 0.98.2
'@hono/node-server': 2.0.6
'@hono/node-server': 2.0.5
'@iconify-json/heroicons': 1.2.3
'@iconify-json/ri': 1.2.10
'@lexical/code': 0.46.0
'@lexical/link': 0.46.0
'@lexical/list': 0.46.0
'@lexical/react': 0.46.0
'@lexical/selection': 0.46.0
'@lexical/text': 0.46.0
'@lexical/utils': 0.46.0
'@lexical/code': 0.45.0
'@lexical/link': 0.45.0
'@lexical/list': 0.45.0
'@lexical/react': 0.45.0
'@lexical/selection': 0.45.0
'@lexical/text': 0.45.0
'@lexical/utils': 0.45.0
'@mdx-js/loader': 3.1.1
'@mdx-js/react': 3.1.1
'@mdx-js/rollup': 3.1.1
@@ -86,10 +86,10 @@ catalog:
'@orpc/contract': 1.14.6
'@orpc/openapi-client': 1.14.6
'@orpc/tanstack-query': 1.14.6
'@playwright/test': 1.61.1
'@playwright/test': 1.61.0
'@remixicon/react': 4.9.0
'@rgrove/parse-xml': 4.2.1
'@sentry/react': 10.62.0
'@rgrove/parse-xml': 4.2.0
'@sentry/react': 10.59.0
'@storybook/addon-a11y': 10.4.6
'@storybook/addon-docs': 10.4.6
'@storybook/addon-links': 10.4.6
@@ -105,13 +105,13 @@ catalog:
'@tailwindcss/postcss': 4.3.1
'@tailwindcss/typography': 0.5.20
'@tailwindcss/vite': 4.3.1
'@tanstack/eslint-plugin-query': 5.101.2
'@tanstack/eslint-plugin-query': 5.101.0
'@tanstack/form-core': 1.33.0
'@tanstack/query-core': 5.101.2
'@tanstack/query-core': 5.101.0
'@tanstack/react-form': 1.33.0
'@tanstack/react-hotkeys': 0.10.0
'@tanstack/react-query': 5.101.2
'@tanstack/react-virtual': 3.14.4
'@tanstack/react-query': 5.101.0
'@tanstack/react-virtual': 3.14.3
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
@@ -128,10 +128,10 @@ catalog:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3
'@types/sortablejs': 1.15.9
'@typescript-eslint/eslint-plugin': 8.62.0
'@typescript-eslint/parser': 8.62.0
'@typescript/native-preview': 7.0.0-dev.20260627.2
'@vitejs/plugin-react': 6.0.3
'@typescript-eslint/eslint-plugin': 8.61.1
'@typescript-eslint/parser': 8.61.1
'@typescript/native-preview': 7.0.0-dev.20260620.1
'@vitejs/plugin-react': 6.0.2
'@vitejs/plugin-rsc': 0.5.27
'@vitest/browser': 4.1.9
'@vitest/browser-playwright': 4.1.9
@@ -145,10 +145,10 @@ catalog:
cli-table3: 0.6.5
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.6.2
code-inspector-plugin: 1.6.1
concurrently: ^10.0.3
copy-to-clipboard: 4.0.2
cron-parser: 5.6.1
cron-parser: 5.6.0
dayjs: 1.11.21
decimal.js: 10.6.0
dompurify: 3.4.11
@@ -159,8 +159,8 @@ catalog:
embla-carousel-fade: 8.6.0
embla-carousel-react: 8.6.0
emoji-mart: 5.6.0
es-toolkit: 1.49.0
eslint: 10.6.0
es-toolkit: 1.47.1
eslint: 10.5.0
eslint-markdown: 0.11.0
eslint-plugin-better-tailwindcss: 4.6.0
eslint-plugin-hyoban: 0.14.1
@@ -172,14 +172,14 @@ catalog:
eslint-plugin-storybook: 10.4.6
eventsource-parser: 3.1.0
fast-deep-equal: 3.1.3
foxact: 0.3.8
foxact: 0.3.7
fuse.js: 7.4.2
happy-dom: 20.10.6
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.27
hono: 4.12.26
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.3.3
i18next: 26.3.1
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.2.0
immer: 11.1.8
@@ -188,31 +188,31 @@ catalog:
jotai-tanstack-query: 0.11.0
js-audio-recorder: 1.0.7
js-cookie: 3.0.8
js-yaml: 4.3.0
js-yaml: 4.2.0
jsonschema: 1.5.0
katex: 0.17.0
knip: 6.22.0
knip: 6.17.1
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.46.0
lexical: 0.45.0
lockfile: 1.0.4
loro-crdt: 1.13.6
mermaid: 11.16.0
loro-crdt: 1.13.5
mermaid: 11.15.0
mime: 4.1.0
mitt: 3.0.1
motion: 12.42.0
motion: 12.40.0
negotiator: 1.0.0
next: 16.2.9
next-themes: 0.4.6
nuqs: 2.8.9
open: 11.0.0
ora: 9.4.1
ora: 9.4.0
picocolors: 1.1.1
pinyin-pro: 3.28.1
playwright: 1.61.1
postcss: 8.5.16
playwright: 1.61.0
postcss: 8.5.15
qrcode.react: 4.2.0
qs: 6.15.3
qs: 6.15.2
react: 19.2.7
react-dom: 19.2.7
react-easy-crop: 6.0.2
@@ -229,7 +229,7 @@ catalog:
scheduler: 0.27.0
server-only: 0.0.1
sharp: 0.35.2
shiki: 4.3.0
shiki: 4.2.0
socket.io-client: 4.8.3
sortablejs: 1.15.7
std-semver: 1.0.8
@@ -238,7 +238,7 @@ catalog:
string-ts: 2.3.1
tailwind-merge: 3.6.0
tailwindcss: 4.3.1
tldts: 7.4.4
tldts: 7.4.3
tsx: 4.22.4
typescript: 6.0.3
uglify-js: 3.19.3
@@ -246,7 +246,7 @@ catalog:
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 14.0.1
vinext: 0.1.8
vinext: 0.1.6
vite: npm:@voidzero-dev/vite-plus-core@0.2.1
vite-plugin-inspect: 12.0.0-beta.3
vite-plus: 0.2.1
@@ -232,20 +232,7 @@ describe('Billing Page + Plan Integration', () => {
// Verify billing URL button visibility and behavior
describe('Billing URL button', () => {
it('should show billing button when manager has subscription management permission', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['billing.subscription.manage'],
})
render(<Billing />)
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when subscription management permission is granted without manager role', () => {
it('should show billing button when subscription management permission is granted', () => {
setupProviderContext({ type: Plan.sandbox })
setupAppContext({
isCurrentWorkspaceManager: false,
@@ -254,7 +241,8 @@ describe('Billing Page + Plan Integration', () => {
render(<Billing />)
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
})
it('should hide billing button when subscription management permission is missing', () => {
@@ -192,7 +192,7 @@ describe('SnippetInfoDropdown', () => {
await user.click(screen.getByRole('button'))
expect(screen.getByText('snippet.menu.editInfo')).toBeInTheDocument()
expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument()
expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument()
expect(screen.queryByText('snippet.menu.deleteSnippet')).not.toBeInTheDocument()
unmount()
@@ -201,7 +201,7 @@ describe('SnippetInfoDropdown', () => {
await user.click(screen.getByRole('button'))
expect(screen.queryByText('snippet.menu.editInfo')).not.toBeInTheDocument()
expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument()
expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument()
expect(screen.getByText('snippet.menu.deleteSnippet')).toBeInTheDocument()
})
})
@@ -244,7 +244,7 @@ describe('SnippetInfoDropdown', () => {
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys = ['snippets.create_and_modify']
mockWorkspacePermissionKeys = ['snippets.management']
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
@@ -264,7 +264,7 @@ describe('SnippetInfoDropdown', () => {
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockWorkspacePermissionKeys = ['snippets.create_and_modify']
mockWorkspacePermissionKeys = ['snippets.management']
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
@@ -58,7 +58,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
}, [])
const handleExportSnippet = React.useCallback(async () => {
if (!canCreateAndModifySnippet)
if (!canManageSnippet)
return
setOpen(false)
@@ -70,7 +70,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
catch {
toast.error(t('exportFailed'))
}
}, [canCreateAndModifySnippet, exportSnippetMutation, snippet.id, snippet.name, t])
}, [canManageSnippet, exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description }: {
name: string
@@ -125,20 +125,18 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
popupClassName="w-[180px] p-1"
>
{canCreateAndModifySnippet && (
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
)}
{canManageSnippet && (
<>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
</>
)}
{canManageSnippet && (
<>
{canCreateAndModifySnippet && <DropdownMenuSeparator className="my-1! bg-divider-subtle" />}
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
variant="destructive"
@@ -21,7 +21,6 @@ let mockChatConversationDetail: Record<string, unknown> | undefined
let mockCompletionConversationDetail: Record<string, unknown> | undefined
let mockShowMessageLogModal = false
let mockShowPromptLogModal = false
let mockShowAgentLogModal = false
let mockCurrentLogItem: Record<string, unknown> | undefined
let mockCurrentLogModalActiveTab = 'messages'
@@ -82,7 +81,6 @@ vi.mock('@/app/components/app/store', () => ({
setShowAgentLogModal: mockSetShowAgentLogModal,
setShowMessageLogModal: mockSetShowMessageLogModal,
showPromptLogModal: mockShowPromptLogModal,
showAgentLogModal: mockShowAgentLogModal,
currentLogModalActiveTab: mockCurrentLogModalActiveTab,
}),
}))
@@ -128,7 +126,6 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited,
onAnnotationRemoved,
switchSibling,
hideLogModal,
}: {
chatList: Array<{ id: string }>
onFeedback: (mid: string, value: { rating: string, content?: string }) => Promise<boolean>
@@ -136,9 +133,8 @@ vi.mock('@/app/components/base/chat/chat', () => ({
onAnnotationEdited: (query: string, answer: string, index: number) => void
onAnnotationRemoved: (index: number) => Promise<boolean>
switchSibling: (siblingMessageId: string) => void
hideLogModal?: boolean
}) => (
<div data-testid="chat-panel" data-hide-log-modal={String(hideLogModal)}>
<div data-testid="chat-panel">
<div>{chatList.length}</div>
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>chat-feedback</button>
<button onClick={() => onAnnotationAdded('annotation-2', 'Admin', 'Edited question', 'Edited answer', 1)}>chat-add-annotation</button>
@@ -149,14 +145,6 @@ vi.mock('@/app/components/base/chat/chat', () => ({
),
}))
vi.mock('@/app/components/base/agent-log-modal', () => ({
default: ({ floating, onCancel }: { floating?: boolean, onCancel: () => void }) => (
<div data-testid="agent-log-modal" data-floating={String(floating)}>
<button onClick={onCancel}>close-agent-log-modal</button>
</div>
),
}))
vi.mock('@/app/components/base/message-log-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div data-testid="message-log-modal">
@@ -267,7 +255,6 @@ describe('ConversationList', () => {
mockCompletionConversationDetail = undefined
mockShowMessageLogModal = false
mockShowPromptLogModal = false
mockShowAgentLogModal = false
mockCurrentLogItem = undefined
mockCurrentLogModalActiveTab = 'messages'
mockDelAnnotation.mockResolvedValue(undefined)
@@ -396,7 +383,6 @@ describe('ConversationList', () => {
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
expect(screen.getByTestId('message-log-modal')).toBeInTheDocument()
fireEvent.click(screen.getByText('chat-feedback'))
@@ -413,61 +399,6 @@ describe('ConversationList', () => {
})
})
it('should mount agent log modals from the detail panel instead of the nested chat layout', async () => {
mockChatConversationDetail = {
id: 'conversation-1',
created_at: 1710000000,
model_config: {
model: 'gpt-4o',
configs: {
introduction: 'Hello there',
},
user_input_form: [],
},
message: {
inputs: {},
},
}
mockShowAgentLogModal = true
mockCurrentLogItem = {
id: 'message-1',
conversationId: 'conversation-1',
}
mockFetchChatMessages.mockResolvedValue({
data: [
{
id: 'message-1',
answer: 'Assistant reply',
query: 'Latest question',
created_at: 1710000000,
inputs: {},
feedbacks: [],
message: [],
message_files: [],
agent_thoughts: [{ id: 'thought-1' }],
},
],
has_more: false,
})
renderConversationList({
searchParams: '?page=2&conversation_id=conversation-1',
})
await waitFor(() => {
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
})
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
expect(screen.getByTestId('agent-log-modal')).toHaveAttribute('data-floating', 'true')
fireEvent.click(screen.getByText('close-agent-log-modal'))
expect(mockSetCurrentLogItem).toHaveBeenCalled()
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
})
it('should render completion details and refetch after feedback updates', async () => {
mockCompletionConversationDetail = {
id: 'conversation-1',
@@ -493,7 +424,7 @@ describe('ConversationList', () => {
},
}
mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
mockCurrentLogItem = { id: 'log-2' }
renderConversationList({
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
@@ -695,7 +626,7 @@ describe('ConversationList', () => {
},
}
mockShowPromptLogModal = true
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
mockCurrentLogItem = { id: 'log-2' }
renderConversationList({
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
+2 -29
View File
@@ -36,7 +36,6 @@ import ModelInfo from '@/app/components/app/log/model-info'
import { useStore as useAppStore } from '@/app/components/app/store'
import TextGeneration from '@/app/components/app/text-generate/item'
import ActionButton from '@/app/components/base/action-button'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Chat from '@/app/components/base/chat/chat'
import CopyIcon from '@/app/components/base/copy-icon'
import Loading from '@/app/components/base/loading'
@@ -166,25 +165,13 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
})
const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext)
const {
currentLogItem,
setCurrentLogItem,
showMessageLogModal,
setShowMessageLogModal,
showPromptLogModal,
setShowPromptLogModal,
showAgentLogModal,
setShowAgentLogModal,
currentLogModalActiveTab,
} = useAppStore(useShallow((state: AppStoreState) => ({
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showMessageLogModal: state.showMessageLogModal,
setShowMessageLogModal: state.setShowMessageLogModal,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
currentLogModalActiveTab: state.currentLogModalActiveTab,
})))
const { t } = useTranslation()
@@ -408,7 +395,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION
const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
const shouldShowPromptLogModal = showPromptLogModal && !!currentLogItem?.log
const varList = getDetailVarList(detail, varValues)
const message_files = getCompletionMessageFiles(detail, isChatMode)
@@ -521,7 +507,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
noChatInput
showPromptLog
hideProcessDetail
hideLogModal
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
@@ -561,7 +546,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
noChatInput
showPromptLog
hideProcessDetail
hideLogModal
chatContainerInnerClassName="px-3"
switchSibling={switchSibling}
/>
@@ -590,18 +574,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
/>
</WorkflowContextProvider>
)}
{showAgentLogModal && (
<AgentLogModal
floating
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
{shouldShowPromptLogModal && (
{!isChatMode && showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
@@ -426,18 +426,16 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should render sort filter before search and the snippets link', () => {
it('should render sort filter before search and hide the snippets link', () => {
renderList()
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
expect(snippetsLink).toHaveAttribute('href', '/snippets')
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(searchInput.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
@@ -8,7 +8,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import Link from '@/next/link'
import { AppSortFilter } from './app-sort-filter'
import { AppTypeFilter } from './app-type-filter'
import CreatorsFilter from './creators-filter'
@@ -71,12 +70,6 @@ export function AppListHeaderFilters({
/>
</div>
<div className="flex items-center gap-2">
<Link
href="/snippets"
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
{showCreateButton && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
@@ -119,17 +119,6 @@ describe('AgentLogModal', () => {
})
})
it('should render the floating modal through a dialog portal', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
const { container } = render(<AgentLogModal {...mockProps} floating />)
const modal = screen.getByRole('dialog')
expect(container).not.toContainElement(modal)
expect(document.body).toContainElement(modal)
expect(modal).toHaveClass('fixed', 'z-50', 'w-[480px]!', 'left-[max(8px,calc(100vw-1136px))]!')
})
it('should call onCancel when close button is clicked', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
@@ -169,18 +158,4 @@ describe('AgentLogModal', () => {
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
it('should not use click-away to close the floating dialog', () => {
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
let clickAwayHandler!: (event: Event) => void
vi.mocked(useClickAway).mockImplementation((callback) => {
clickAwayHandler = callback
})
render(<AgentLogModal {...mockProps} floating />)
clickAwayHandler(new Event('click'))
expect(mockProps.onCancel).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { RiCloseLine } from '@remixicon/react'
import { useClickAway } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
@@ -11,13 +10,11 @@ import AgentLogDetail from './detail'
type AgentLogModalProps = Readonly<{
currentLogItem?: IChatItem
width: number
floating?: boolean
onCancel: () => void
}>
const AgentLogModal: FC<AgentLogModalProps> = ({
currentLogItem,
width,
floating,
onCancel,
}) => {
const { t } = useTranslation()
@@ -25,7 +22,7 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
const [mounted, setMounted] = useState(false)
useClickAway(() => {
if (mounted && !floating)
if (mounted)
onCancel()
}, ref)
@@ -36,44 +33,6 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
if (!currentLogItem || !currentLogItem.conversationId)
return null
const detailContent = (
<>
<AgentLogDetail
conversationID={currentLogItem.conversationId}
messageID={currentLogItem.id}
log={currentLogItem}
/>
</>
)
if (floating) {
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropClassName="bg-transparent!"
className="top-16! bottom-4! left-[max(8px,calc(100vw-1136px))]! flex max-h-none! w-[480px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! flex-col overflow-hidden! rounded-xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! pt-3! pb-3! shadow-xl!"
>
<DialogTitle className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</DialogTitle>
<button
type="button"
aria-label={t('operation.close', { ns: 'common' })}
className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onCancel}
>
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
{detailContent}
</DialogContent>
</Dialog>
)
}
return (
<div
className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl')}
@@ -95,7 +54,11 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
>
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
</button>
{detailContent}
<AgentLogDetail
conversationID={currentLogItem.conversationId}
messageID={currentLogItem.id}
log={currentLogItem}
/>
</div>
)
}
@@ -61,7 +61,7 @@ const ChatWrapper = () => {
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const timezone = appSourceType === AppSourceType.webApp
? new Intl.DateTimeFormat().resolvedOptions().timeZone
? Intl.DateTimeFormat().resolvedOptions().timeZone
: undefined
// Semantic variable for better code readability
@@ -1,7 +1,7 @@
import type { ChatConfig, ChatItemInTree } from '../../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { act, renderHook } from '@testing-library/react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useParams, usePathname } from '@/next/navigation'
import { sseGet, ssePost } from '@/service/base'
import { useChat } from '../hooks'
@@ -353,6 +353,74 @@ describe('useChat', () => {
expect(result.current.chatList[1]!.id).toBe('m-1')
})
it('should process inputs with the per-send input form override', () => {
vi.mocked(ssePost).mockImplementation(async () => undefined)
const { result } = renderHook(() => useChat(undefined, {
inputs: {},
inputsForm: [{
type: InputVarType.textInput,
label: 'City',
variable: 'city',
required: true,
hide: false,
}],
}))
act(() => {
result.current.handleSend('test-url', {
query: 'hello',
inputs: {
enabled: undefined,
},
overrideInputsForm: [{
type: InputVarType.checkbox,
label: 'Enabled',
variable: 'enabled',
required: true,
hide: false,
}],
}, {})
})
expect(ssePost).toHaveBeenCalledWith(
'test-url',
expect.objectContaining({
body: expect.objectContaining({
inputs: {
enabled: false,
},
}),
}),
expect.any(Object),
)
})
it('should settle a send once when the SSE stream errors', () => {
let callbacks: HookCallbacks
const onSendSettled = vi.fn()
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'hello' }, {
onSendSettled,
})
})
act(() => {
callbacks.onError()
callbacks.onCompleted(true)
})
expect(result.current.isResponding).toBe(false)
expect(onSendSettled).toHaveBeenCalledTimes(1)
expect(onSendSettled).toHaveBeenCalledWith(true)
})
it('should handle onThought and different workflow events', async () => {
let callbacks: HookCallbacks
@@ -1106,6 +1174,44 @@ describe('useChat', () => {
expect(result.current.isResponding).toBe(false)
})
it('should settle a resume once when the event stream errors', () => {
let callbacks: HookCallbacks
const onSendSettled = vi.fn()
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const prevChatTree = [{
id: 'q-1',
content: 'query',
isAnswer: false,
children: [{
id: 'm-resume-error',
content: 'initial',
isAnswer: true,
siblingIndex: 0,
}],
}]
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[]))
act(() => {
result.current.handleResume('m-resume-error', 'wr-error', {
isPublicAPI: true,
onSendSettled,
})
})
act(() => {
callbacks.onError()
callbacks.onCompleted(true)
})
expect(onSendSettled).toHaveBeenCalledTimes(1)
expect(onSendSettled).toHaveBeenCalledWith(true)
expect(result.current.isResponding).toBe(false)
})
it('should abort previous workflow event stream when resuming again', () => {
const callbacksList: HookCallbacks[] = []
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
@@ -1489,6 +1595,45 @@ describe('useChat', () => {
expect(clearChatListCallback).toHaveBeenCalledWith(false)
})
it('should keep the first send after a reset acknowledgement', () => {
let clearChatList = true
const clearChatListCallback = vi.fn((nextClearChatList: boolean) => {
clearChatList = nextClearChatList
})
const { rerender, result } = renderHook(() => useChat(
undefined,
undefined,
undefined,
undefined,
clearChatList,
clearChatListCallback,
))
expect(clearChatListCallback).toHaveBeenCalledWith(false)
rerender()
act(() => {
result.current.handleSend('test-url', { query: 'first after reset' }, {})
})
expect(ssePost).toHaveBeenCalledWith(
'test-url',
expect.objectContaining({
body: expect.objectContaining({
query: 'first after reset',
}),
}),
expect.any(Object),
)
expect(result.current.chatList).toEqual(expect.arrayContaining([
expect.objectContaining({
content: 'first after reset',
isAnswer: false,
}),
]))
})
})
describe('annotations and siblings', () => {
@@ -361,6 +361,32 @@ describe('ChatInputArea', () => {
expect(textarea).toHaveValue('')
})
it('should keep the textarea when async send is rejected by the owner', async () => {
const user = userEvent.setup({ delay: null })
const onSend = vi.fn().mockResolvedValue(false)
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()!
await user.type(textarea, 'Keep this message')
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
await waitFor(() => expect(onSend).toHaveBeenCalled())
expect(textarea).toHaveValue('Keep this message')
})
it('should keep the textarea when async send fails', async () => {
const user = userEvent.setup({ delay: null })
const onSend = vi.fn().mockRejectedValue(new Error('send failed'))
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
const textarea = getTextarea()!
await user.type(textarea, 'Retry this message')
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
await waitFor(() => expect(onSend).toHaveBeenCalled())
expect(textarea).toHaveValue('Retry this message')
})
it('should call onSend and reset the input when pressing Enter', async () => {
const user = userEvent.setup({ delay: null })
const onSend = vi.fn()
@@ -24,6 +24,8 @@ type AudioRecorderWithPermission = typeof Recorder & {
getPermission: () => Promise<void>
}
type SendAcceptance = void | boolean | Promise<void | boolean>
type ChatInputAreaProps = {
readonly?: boolean
botName?: string
@@ -62,10 +64,19 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
const historyRef = useRef([''])
const [currentIndex, setCurrentIndex] = useState(-1)
const isComposingRef = useRef(false)
const queryRef = useRef('')
const handleQueryChange = useCallback((value: string) => {
queryRef.current = value
setQuery(value)
setTimeout(handleTextareaResize, 0)
}, [handleTextareaResize])
const resetAcceptedMessage = useCallback((acceptedQuery: string, acceptedFiles: ReturnType<typeof filesStore.getState>['files']) => {
const { files, setFiles } = filesStore.getState()
if (queryRef.current === acceptedQuery)
handleQueryChange('')
if (files === acceptedFiles)
setFiles([])
}, [filesStore, handleQueryChange])
const handleSend = () => {
if (!canSend)
return
@@ -75,15 +86,23 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
return
}
if (onSend) {
const { files, setFiles } = filesStore.getState()
const { files } = filesStore.getState()
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
return
}
if (checkInputsForm(inputs, inputsForm)) {
onSend(query, files)
handleQueryChange('')
setFiles([])
const sendResult = onSend(query, files) as SendAcceptance
if (sendResult instanceof Promise) {
sendResult.then((accepted) => {
if (accepted !== false)
resetAcceptedMessage(query, files)
}).catch(noop)
return
}
if (sendResult !== false)
resetAcceptedMessage(query, files)
}
}
}
+98 -68
View File
@@ -52,6 +52,7 @@ type SendCallback = {
onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
onConversationComplete?: (conversationId: string) => void
onSendSettled?: (hasError?: boolean) => void
isPublicAPI?: boolean
}
@@ -256,10 +257,19 @@ export const useChat = (
{
onGetSuggestedQuestions,
onConversationComplete,
onSendSettled,
isPublicAPI,
}: SendCallback,
) => {
const getOrCreatePlayer = createAudioPlayerManager()
let hasSettled = false
const settleSend = (hasError?: boolean) => {
if (hasSettled)
return
hasSettled = true
onSendSettled?.(hasError)
}
// Re-subscribe to workflow events for the specific message
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
@@ -303,25 +313,29 @@ export const useChat = (
async onCompleted(hasError?: boolean) {
handleResponding(false)
if (hasError)
return
try {
if (hasError)
return
if (onConversationComplete)
onConversationComplete(conversationIdRef.current)
if (onConversationComplete)
onConversationComplete(conversationIdRef.current)
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
messageId,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestedQuestions(data)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setSuggestedQuestions([])
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
messageId,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestedQuestions(data)
}
catch {
setSuggestedQuestions([])
}
}
}
finally {
settleSend(hasError)
}
},
onFile(file) {
// Convert simple file type to MIME type for non-agent mode
@@ -404,6 +418,7 @@ export const useChat = (
},
onError() {
handleResponding(false)
settleSend(true)
},
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
handleResponding(true)
@@ -669,6 +684,7 @@ export const useChat = (
onGetConversationMessages,
onGetSuggestedQuestions,
onConversationComplete,
onSendSettled,
isPublicAPI,
}: SendCallback,
) => {
@@ -721,13 +737,14 @@ export const useChat = (
handleResponding(true)
hasStopRespondedRef.current = false
const { query, files, inputs, ...restData } = data
const { query, files, inputs, overrideInputsForm, ...restData } = data
const requestInputsForm = overrideInputsForm ?? formSettings?.inputsForm ?? []
const bodyParams = {
response_mode: 'streaming',
conversation_id: conversationIdRef.current,
files: getProcessedFiles(files || []),
query,
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
inputs: getProcessedInputs(inputs || {}, requestInputsForm),
...restData,
}
if (bodyParams?.files?.length) {
@@ -744,6 +761,14 @@ export const useChat = (
let isAgentMode = false
let hasSetResponseId = false
let hasSettled = false
const settleSend = (hasError?: boolean) => {
if (hasSettled)
return
hasSettled = true
onSendSettled?.(hasError)
}
const getOrCreatePlayer = createAudioPlayerManager()
@@ -802,63 +827,67 @@ export const useChat = (
async onCompleted(hasError?: boolean) {
handleResponding(false)
if (hasError)
return
if (onConversationComplete)
onConversationComplete(conversationIdRef.current)
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
const { data }: any = await onGetConversationMessages(
conversationIdRef.current,
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
)
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
if (!newResponseItem)
try {
if (hasError)
return
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
updateChatTreeNode(responseItem.id, {
content: isUseAgentThought ? '' : newResponseItem.answer,
log: [
...newResponseItem.message,
...(newResponseItem.message.at(-1).role !== 'assistant'
? [
{
role: 'assistant',
text: newResponseItem.answer,
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
},
]
: []),
],
more: {
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
latency: newResponseItem.provider_response_latency.toFixed(2),
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
},
// for agent log
conversationId: conversationIdRef.current,
input: {
inputs: newResponseItem.inputs,
query: newResponseItem.query,
},
})
}
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
if (onConversationComplete)
onConversationComplete(conversationIdRef.current)
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
const { data }: any = await onGetConversationMessages(
conversationIdRef.current,
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
)
setSuggestedQuestions(data)
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
if (!newResponseItem)
return
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
updateChatTreeNode(responseItem.id, {
content: isUseAgentThought ? '' : newResponseItem.answer,
log: [
...newResponseItem.message,
...(newResponseItem.message.at(-1).role !== 'assistant'
? [
{
role: 'assistant',
text: newResponseItem.answer,
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
},
]
: []),
],
more: {
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
latency: newResponseItem.provider_response_latency.toFixed(2),
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
},
// for agent log
conversationId: conversationIdRef.current,
input: {
inputs: newResponseItem.inputs,
query: newResponseItem.query,
},
})
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setSuggestedQuestions([])
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestedQuestions(data)
}
catch {
setSuggestedQuestions([])
}
}
}
finally {
settleSend(hasError)
}
},
onFile(file) {
// Convert simple file type to MIME type for non-agent mode
@@ -965,6 +994,7 @@ export const useChat = (
},
onError() {
handleResponding(false)
settleSend(true)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
@@ -85,7 +85,7 @@ const ChatWrapper = () => {
} as ChatConfig
}, [appParams, currentConversationItem?.introduction])
const timezone = appSourceType === AppSourceType.webApp
? new Intl.DateTimeFormat().resolvedOptions().timeZone
? Intl.DateTimeFormat().resolvedOptions().timeZone
: undefined
const {
chatList,
@@ -1,5 +1,3 @@
import type { CSSProperties } from 'react'
export function hexToRGBA(hex: string, opacity: number): string {
hex = hex.replace('#', '')
@@ -15,11 +13,11 @@ export function hexToRGBA(hex: string, opacity: number): string {
* Since strings cannot be directly assigned to the 'style' attribute in JSX,
* this method transforms the string into an object representation of the styles.
*/
export function CssTransform(cssString: string): CSSProperties {
export function CssTransform(cssString: string): object {
if (cssString.length === 0)
return {}
const style: CSSProperties = {}
const style: object = {}
const propertyValuePairs = cssString.split(';')
for (const pair of propertyValuePairs) {
if (pair.trim().length > 0) {
@@ -532,54 +532,6 @@ describe('MarkdownForm', () => {
})
})
// Unicode letters should be valid form field names.
describe('Unicode name support', () => {
it('should include Unicode-named fields from all supported controls in JSON output', async () => {
const user = userEvent.setup()
const node = createRootNode(
[
createElementNode('label', { for: '用户名' }, [createTextNode('Username:')]),
createElementNode('input', { type: 'text', name: '用户名', value: 'Alice' }),
createElementNode('label', { for: '密码' }, [createTextNode('Password:')]),
createElementNode('input', { type: 'password', name: '密码', value: 'secret' }),
createElementNode('label', { for: '内容' }, [createTextNode('Content:')]),
createElementNode('textarea', { name: '内容', value: 'Hello' }),
createElementNode('label', { for: '日期' }, [createTextNode('Date:')]),
createElementNode('input', { type: 'date', name: '日期', value: dayjs('2026-01-10') }),
createElementNode('label', { for: '时间' }, [createTextNode('Time:')]),
createElementNode('input', { type: 'time', name: '时间', value: '09:00' }),
createElementNode('label', { for: '日期时间' }, [createTextNode('Datetime:')]),
createElementNode('input', { type: 'datetime', name: '日期时间', value: dayjs('2026-01-10T08:30:00') }),
createElementNode('label', { for: 'café' }, [createTextNode('Select:')]),
createElementNode('input', { type: 'select', name: 'café', value: 'hello', dataOptions: ['hello', 'world'] }),
createElementNode('input', { type: 'checkbox', name: '同意条款', value: true, dataTip: 'By checking this means you agreed' }),
createElementNode('button', { dataSize: 'small', dataVariant: 'primary' }, [createTextNode('Login')]),
],
{ dataFormat: 'json' },
)
render(<MarkdownForm node={node} />)
await user.click(screen.getByRole('button', { name: 'Login' }))
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalled()
})
const submittedPayload = JSON.parse(mockOnSend.mock.calls[0]![0]) as Record<string, unknown>
expect(submittedPayload).toMatchObject({
: 'Alice',
: 'secret',
: 'Hello',
: 'formatted-date',
: 'formatted-datetime',
café: 'hello',
同意条款: true,
})
expect(submittedPayload).toHaveProperty('时间')
})
})
// Double-click protection: button disables after the first submit.
describe('Double submit prevention', () => {
it('should disable submit button after first click', async () => {
@@ -41,15 +41,7 @@ type SupportedType = typeof SUPPORTED_TYPES[keyof typeof SUPPORTED_TYPES]
const SUPPORTED_TYPES_SET = new Set<string>(Object.values(SUPPORTED_TYPES))
const SAFE_NAME_RE = (() => {
try {
return new RegExp('^\\p{L}[\\p{L}\\p{M}\\p{N}_-]*$', 'u')
}
catch {
// Fallback for browsers without Unicode property escape support.
return /^[a-z][\w-]*$/i
}
})()
const SAFE_NAME_RE = /^[a-z][\w-]*$/i
const PROTOTYPE_POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
function isSafeName(name: unknown): name is string {
@@ -6,7 +6,6 @@ let fetching = false
let isManager = true
let enableBilling = true
let workspacePermissionKeys: string[] = ['billing.subscription.manage']
let billingUrlEnabled = false
const refetchMock = vi.fn()
const openAsyncWindowMock = vi.fn()
@@ -20,14 +19,11 @@ type BillingWindowOptions = {
type OpenAsyncWindowCall = [BillingUrlCallback, BillingWindowOptions]
vi.mock('@/service/use-billing', () => ({
useBillingUrl: (enabled: boolean) => {
billingUrlEnabled = enabled
return {
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}
},
useBillingUrl: () => ({
data: currentBillingUrl,
isFetching: fetching,
refetch: refetchMock,
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
@@ -58,32 +54,28 @@ describe('Billing', () => {
fetching = false
isManager = true
enableBilling = true
billingUrlEnabled = false
workspacePermissionKeys = ['billing.subscription.manage']
refetchMock.mockResolvedValue({ data: 'https://billing' })
})
it('hides the billing action when subscription management permission is granted without manager role', () => {
it('shows the billing action when subscription management permission is granted without manager role', () => {
isManager = false
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
})
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
workspacePermissionKeys = []
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
vi.clearAllMocks()
workspacePermissionKeys = ['billing.subscription.manage']
enableBilling = false
render(<Billing />)
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
expect(billingUrlEnabled).toBe(false)
})
it('opens the billing window with the immediate url when the button is clicked', async () => {
@@ -11,9 +11,9 @@ import PlanComp from '../plan'
const Billing: FC = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager, workspacePermissionKeys } = useAppContext()
const { workspacePermissionKeys } = useAppContext()
const { enableBilling } = useProviderContext()
const canManageBillingSubscription = isCurrentWorkspaceManager && hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
const canManageBillingSubscription = hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && canManageBillingSubscription)
const openAsyncWindow = useAsyncWindowOpen()
+18 -6
View File
@@ -28,6 +28,15 @@ type PricingProps = {
onCancel: () => void
}
const pricingScrollAreaClassNames = {
root: 'relative h-full w-full overflow-hidden',
viewport: 'overscroll-contain',
content: 'min-h-full min-w-[1200px]',
verticalScrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:me-1',
horizontalScrollbar: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-0.5',
corner: 'bg-saas-background',
} as const
const Pricing: FC<PricingProps> = ({
onCancel,
}) => {
@@ -56,9 +65,9 @@ const Pricing: FC<PricingProps> = ({
<DialogContent
className="inset-0 size-full max-h-none max-w-none translate-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none"
>
<ScrollAreaRoot className="relative h-full w-full overflow-hidden">
<ScrollAreaViewport className="overscroll-contain">
<ScrollAreaContent className="min-h-full min-w-300">
<ScrollAreaRoot className={pricingScrollAreaClassNames.root}>
<ScrollAreaViewport className={pricingScrollAreaClassNames.viewport}>
<ScrollAreaContent className={pricingScrollAreaClassNames.content}>
<div className="relative grid min-h-full grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<div className="absolute inset-x-0 -top-12 -z-10">
<NoiseTop />
@@ -83,13 +92,16 @@ const Pricing: FC<PricingProps> = ({
</div>
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaScrollbar className={pricingScrollAreaClassNames.verticalScrollbar}>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar orientation="horizontal">
<ScrollAreaScrollbar
orientation="horizontal"
className={pricingScrollAreaClassNames.horizontalScrollbar}
>
<ScrollAreaThumb className="rounded-full" />
</ScrollAreaScrollbar>
<ScrollAreaCorner className="bg-saas-background" />
<ScrollAreaCorner className={pricingScrollAreaClassNames.corner} />
</ScrollAreaRoot>
</DialogContent>
</Dialog>
+7 -4
View File
@@ -23,6 +23,12 @@ import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/s
import Item from './app-nav-item'
import NoApps from './no-apps'
const expandedSidebarScrollAreaClassNames = {
content: 'space-y-0.5',
scrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3',
viewport: 'overscroll-contain',
} as const
const SideBar = () => {
const { t } = useTranslation()
const pathname = usePathname()
@@ -110,10 +116,7 @@ const SideBar = () => {
<div className="min-h-0 flex-1">
<ScrollArea
className="h-full"
slotClassNames={{
viewport: 'overscroll-contain',
content: 'space-y-0.5 pr-3',
}}
slotClassNames={expandedSidebarScrollAreaClassNames}
labelledBy={webAppsLabelId}
>
{installedAppItems}

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