diff --git a/api/clients/agent_backend/__init__.py b/api/clients/agent_backend/__init__.py index 172d63858a5..67175c4795c 100644 --- a/api/clients/agent_backend/__init__.py +++ b/api/clients/agent_backend/__init__.py @@ -18,6 +18,7 @@ from clients.agent_backend.errors import ( AgentBackendValidationError, ) from clients.agent_backend.event_adapter import ( + AgentBackendAgentMessageDeltaInternalEvent, AgentBackendDeferredToolCallInternalEvent, AgentBackendInternalEvent, AgentBackendInternalEventType, @@ -46,6 +47,11 @@ from clients.agent_backend.request_builder import ( AgentBackendWorkflowNodeRunInput, redact_for_agent_backend_log, ) +from clients.agent_backend.session_cleanup import ( + AgentBackendSessionCleanupPayload, + AgentBackendSessionCleanupResult, + cleanup_agent_backend_session, +) __all__ = [ "AGENT_SOUL_PROMPT_LAYER_ID", @@ -57,6 +63,7 @@ __all__ = [ "WORKFLOW_NODE_JOB_PROMPT_LAYER_ID", "WORKFLOW_USER_PROMPT_LAYER_ID", "AgentBackendAgentAppRunInput", + "AgentBackendAgentMessageDeltaInternalEvent", "AgentBackendDeferredToolCallInternalEvent", "AgentBackendError", "AgentBackendHTTPError", @@ -73,6 +80,8 @@ __all__ = [ "AgentBackendRunRequestBuilder", "AgentBackendRunStartedInternalEvent", "AgentBackendRunSucceededInternalEvent", + "AgentBackendSessionCleanupPayload", + "AgentBackendSessionCleanupResult", "AgentBackendStreamError", "AgentBackendStreamInternalEvent", "AgentBackendTransportError", @@ -82,6 +91,7 @@ __all__ = [ "FakeAgentBackendRunClient", "FakeAgentBackendScenario", "RuntimeLayerSpec", + "cleanup_agent_backend_session", "create_agent_backend_run_client", "extract_runtime_layer_specs", "redact_for_agent_backend_log", diff --git a/api/clients/agent_backend/event_adapter.py b/api/clients/agent_backend/event_adapter.py index 54c63f15e46..8fdc165ab3c 100644 --- a/api/clients/agent_backend/event_adapter.py +++ b/api/clients/agent_backend/event_adapter.py @@ -5,6 +5,9 @@ The adapter does not define a new cross-service event contract. It consumes workflow Agent Node maps to Graphon/AppQueue events. Deferred external tool calls remain Dify Agent ``run_succeeded`` payloads on the wire; API code turns them into an internal event so workflow pause/session handling stays local to API. +Agent-message deltas are exposed as annotations on ``PydanticAIStreamRunEvent`` +so API code does not have to parse Pydantic AI stream-event internals to +preserve streaming. The terminal answer remains the ``run_succeeded`` output. """ from __future__ import annotations @@ -32,6 +35,7 @@ class AgentBackendInternalEventType(StrEnum): RUN_STARTED = "run_started" STREAM_EVENT = "stream_event" + AGENT_MESSAGE_DELTA = "agent_message_delta" DEFERRED_TOOL_CALL = "deferred_tool_call" RUN_SUCCEEDED = "run_succeeded" RUN_FAILED = "run_failed" @@ -61,6 +65,13 @@ class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase): data: JsonValue +class AgentBackendAgentMessageDeltaInternalEvent(AgentBackendInternalEventBase): + """API-internal agent-message delta emitted independently from raw stream events.""" + + type: Literal[AgentBackendInternalEventType.AGENT_MESSAGE_DELTA] = AgentBackendInternalEventType.AGENT_MESSAGE_DELTA + delta: str + + class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase): """API-internal terminal success event carrying final output and session state.""" @@ -99,6 +110,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase): type AgentBackendInternalEvent = Annotated[ AgentBackendRunStartedInternalEvent | AgentBackendStreamInternalEvent + | AgentBackendAgentMessageDeltaInternalEvent | AgentBackendDeferredToolCallInternalEvent | AgentBackendRunSucceededInternalEvent | AgentBackendRunFailedInternalEvent @@ -121,6 +133,14 @@ class AgentBackendRunEventAdapter: ) ] case PydanticAIStreamRunEvent(): + if event.agent_message_delta: + return [ + AgentBackendAgentMessageDeltaInternalEvent( + run_id=event.run_id, + source_event_id=event.id, + delta=event.agent_message_delta, + ) + ] data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json")) event_kind = data.get("event_kind") if isinstance(data, dict) else None return [ diff --git a/api/clients/agent_backend/request_builder.py b/api/clients/agent_backend/request_builder.py index 3b7b78f299b..1fd7b2cd990 100644 --- a/api/clients/agent_backend/request_builder.py +++ b/api/clients/agent_backend/request_builder.py @@ -11,8 +11,9 @@ composition-driven. from __future__ import annotations +import re from collections.abc import Mapping -from typing import ClassVar +from typing import ClassVar, Literal from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot @@ -46,7 +47,6 @@ from dify_agent.protocol import ( LayerExitSignals, RunComposition, RunLayerSpec, - RunPurpose, RuntimeLayerSpec, ) from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator @@ -63,6 +63,7 @@ DIFY_CORE_TOOLS_LAYER_ID = "core_tools" DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge" DIFY_ASK_HUMAN_LAYER_ID = "ask_human" DIFY_SHELL_LAYER_ID = "shell" +type AgentConfigVersionKind = Literal["snapshot", "draft", "build_draft"] def _filter_snapshot_to_specs( @@ -104,6 +105,59 @@ def _shell_config_with_drive_ref( return config.model_copy(update={"agent_stub_drive_ref": drive_config.drive_ref}) +def _markdown_backtick_fence(text: str) -> str: + """Choose a fence that will not terminate inside the prompt body.""" + longest_backtick_run = max((len(match.group(0)) for match in re.finditer(r"`+", text)), default=0) + return "`" * max(3, longest_backtick_run + 1) + + +_BUILD_DRAFT_AGENT_SOUL_PROMPT = """You are running in build mode. + +Objective: +- Improve this agent's working environment, configuration, tools, files, notes, + and context so it can handle the intended task well. + +Guidance: +- Treat the intended task as context for setup work, validation, and configuration decisions. +- Perform concrete investigative or setup steps when they help improve or verify the agent configuration. +- Use the installed `dify-agent` CLI when you need to inspect or persist Agent configuration.""" + + +def _wrap_build_draft_agent_soul_prompt(prompt: str | None) -> str: + """Reframe build-draft Agent Soul prompts as preparation work for a future run.""" + prompt_body = (prompt or "").strip() + if not prompt_body: + return _BUILD_DRAFT_AGENT_SOUL_PROMPT + "\n\nIntended task for later normal runs:\nNo task prompt was provided." + fence = _markdown_backtick_fence(prompt_body) + return ( + _BUILD_DRAFT_AGENT_SOUL_PROMPT + + f"\n\nIntended task for later normal runs:\n{fence}text\n{prompt_body}\n{fence}" + ) + + +def _agent_soul_prompt_for_layer( + prompt: str | None, + *, + config_version_kind: AgentConfigVersionKind, +) -> str | None: + """Preserve normal snapshot/draft prompts and only wrap build-draft prompts. + + The API-side layer adapter is the product boundary where Agent Soul text + becomes the model-facing system-prompt layer. ``snapshot`` and normal + ``draft`` runs pass through the original effective prompt unchanged, while + ``build_draft`` always emits a setup prompt. When an original prompt is + present, it is reframed as future-run context and embedded in a fenced + block; when it is blank, the setup instruction is still kept. + """ + if config_version_kind != "build_draft": + if prompt is None: + return None + if not prompt.strip(): + return None + return prompt + return _wrap_build_draft_agent_soul_prompt(prompt) + + class AgentBackendModelConfig(BaseModel): """API-side model/plugin selection before it is converted to Dify Agent layers.""" @@ -163,7 +217,7 @@ class AgentBackendWorkflowNodeRunInput(BaseModel): workflow_node_job_prompt: str user_prompt: str agent_soul_prompt: str | None = None - purpose: RunPurpose = "workflow_node" + agent_config_version_kind: AgentConfigVersionKind = "snapshot" idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None @@ -212,7 +266,7 @@ class AgentBackendAgentAppRunInput(BaseModel): execution_context: DifyExecutionContextLayerConfig user_prompt: str agent_soul_prompt: str | None = None - purpose: RunPurpose = "agent_app" + agent_config_version_kind: AgentConfigVersionKind = "snapshot" idempotency_key: str | None = None output: AgentBackendOutputConfig | None = None tools: DifyPluginToolsLayerConfig | None = None @@ -261,13 +315,17 @@ class AgentBackendRunRequestBuilder: prompt. """ layers: list[RunLayerSpec] = [] - if run_input.agent_soul_prompt: + agent_soul_prompt = _agent_soul_prompt_for_layer( + run_input.agent_soul_prompt, + config_version_kind=run_input.agent_config_version_kind, + ) + if agent_soul_prompt: layers.append( RunLayerSpec( name=AGENT_SOUL_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "agent_soul"}, - config=PromptLayerConfig(prefix=run_input.agent_soul_prompt), + config=PromptLayerConfig(prefix=agent_soul_prompt), ) ) @@ -419,7 +477,6 @@ class AgentBackendRunRequestBuilder: return CreateRunRequest( composition=RunComposition(layers=layers), - purpose=run_input.purpose, idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, @@ -467,7 +524,6 @@ class AgentBackendRunRequestBuilder: filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs) return CreateRunRequest( composition=RunComposition(layers=layers), - purpose="workflow_node", idempotency_key=idempotency_key, metadata=request_metadata, session_snapshot=filtered_snapshot, @@ -483,13 +539,17 @@ class AgentBackendRunRequestBuilder: ask_human / structured output. """ layers: list[RunLayerSpec] = [] - if run_input.agent_soul_prompt: + agent_soul_prompt = _agent_soul_prompt_for_layer( + run_input.agent_soul_prompt, + config_version_kind=run_input.agent_config_version_kind, + ) + if agent_soul_prompt: layers.append( RunLayerSpec( name=AGENT_SOUL_PROMPT_LAYER_ID, type=PLAIN_PROMPT_LAYER_TYPE_ID, metadata={**run_input.metadata, "origin": "agent_soul"}, - config=PromptLayerConfig(prefix=run_input.agent_soul_prompt), + config=PromptLayerConfig(prefix=agent_soul_prompt), ) ) @@ -649,7 +709,6 @@ class AgentBackendRunRequestBuilder: return CreateRunRequest( composition=RunComposition(layers=layers), - purpose=run_input.purpose, idempotency_key=run_input.idempotency_key, metadata=run_input.metadata, session_snapshot=run_input.session_snapshot, diff --git a/api/clients/agent_backend/session_cleanup.py b/api/clients/agent_backend/session_cleanup.py new file mode 100644 index 00000000000..370ff544e68 --- /dev/null +++ b/api/clients/agent_backend/session_cleanup.py @@ -0,0 +1,100 @@ +"""Shared API-side helper for Agent backend lifecycle-only session cleanup. + +Product code owns local row retirement and background-task dispatch. This module +only adapts persisted cleanup inputs into the public ``dify-agent`` run +protocol, performs the synchronous ``create_run + wait_run`` loop used by Celery +workers, and reports whether the backend cleanup succeeded, was skipped, or +failed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Literal + +from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import RuntimeLayerSpec +from pydantic import BaseModel, ConfigDict, Field, JsonValue + +from clients.agent_backend.client import AgentBackendRunClient +from clients.agent_backend.errors import AgentBackendError +from clients.agent_backend.request_builder import AgentBackendRunRequestBuilder + + +class AgentBackendSessionCleanupPayload(BaseModel): + """Serialized cleanup inputs preserved across API and Celery boundaries.""" + + session_snapshot: CompositorSessionSnapshot | None = None + runtime_layer_specs: list[RuntimeLayerSpec] = Field(default_factory=list) + idempotency_key: str | None = None + metadata: dict[str, JsonValue] = Field(default_factory=dict) + timeout_seconds: float = 30.0 + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +@dataclass(frozen=True, slots=True) +class AgentBackendSessionCleanupResult: + """Terminal outcome of one backend cleanup attempt.""" + + status: Literal["succeeded", "skipped", "failed"] + reason: str | None = None + cleanup_run_id: str | None = None + + @classmethod + def succeeded(cls, cleanup_run_id: str) -> AgentBackendSessionCleanupResult: + return cls(status="succeeded", cleanup_run_id=cleanup_run_id) + + @classmethod + def skipped(cls, reason: str) -> AgentBackendSessionCleanupResult: + return cls(status="skipped", reason=reason) + + @classmethod + def failed(cls, reason: str, cleanup_run_id: str | None = None) -> AgentBackendSessionCleanupResult: + return cls(status="failed", reason=reason, cleanup_run_id=cleanup_run_id) + + +def cleanup_agent_backend_session( + *, + payload: AgentBackendSessionCleanupPayload, + client: AgentBackendRunClient | None, + request_builder: AgentBackendRunRequestBuilder | None = None, +) -> AgentBackendSessionCleanupResult: + """Run lifecycle-only cleanup against the Agent backend and report status.""" + if client is None: + return AgentBackendSessionCleanupResult.skipped("no_agent_backend_client") + if payload.session_snapshot is None: + return AgentBackendSessionCleanupResult.skipped("missing_session_snapshot") + if not payload.runtime_layer_specs: + return AgentBackendSessionCleanupResult.skipped("missing_runtime_layer_specs") + + builder = request_builder or AgentBackendRunRequestBuilder() + request = builder.build_cleanup_request( + session_snapshot=payload.session_snapshot, + runtime_layer_specs=payload.runtime_layer_specs, + idempotency_key=payload.idempotency_key, + metadata=payload.metadata, + ) + + try: + response = client.create_run(request) + except AgentBackendError as exc: + return AgentBackendSessionCleanupResult.failed(str(exc)) + + try: + status_response = client.wait_run(response.run_id, timeout_seconds=payload.timeout_seconds) + except AgentBackendError as exc: + return AgentBackendSessionCleanupResult.failed(str(exc), cleanup_run_id=response.run_id) + + if status_response.status != "succeeded": + reason = status_response.error or f"cleanup run ended with status {status_response.status}" + return AgentBackendSessionCleanupResult.failed(reason, cleanup_run_id=response.run_id) + + return AgentBackendSessionCleanupResult.succeeded(response.run_id) + + +__all__ = [ + "AgentBackendSessionCleanupPayload", + "AgentBackendSessionCleanupResult", + "cleanup_agent_backend_session", +] diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 4a8caff9c40..62cf38b86d8 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -36,7 +36,7 @@ from controllers.console.wraps import ( with_current_user_id, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError -from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom from core.app.features.rate_limiting.rate_limit import RateLimitGenerator from core.errors.error import ( ModelCurrentlyNotSupportError, @@ -416,6 +416,7 @@ def _create_build_chat_finalization_message( "draft_type": "debug_build", "conversation_id": debug_conversation_id, "auto_generate_name": False, + AGENT_RUNTIME_EXIT_INTENT_ARG: "delete", } external_trace_id = get_external_trace_id(request) if external_trace_id: diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index f2f62496883..4c10004fc7e 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -1,14 +1,11 @@ """Agent App generator: orchestrate Agent App chat and finalize executions. -The primary mode mirrors the agent_chat generator (conversation + message + +Agent App turns mirror the agent_chat generator (conversation + message + queue + streamed response over the EasyUI chat pipeline), but the backing config comes from the bound Agent Soul and the answer is produced by ``AgentAppRunner`` calling the dify-agent backend rather than an in-process -LLM/ReAct loop. - -It also exposes a stateless build-finalize mode that reuses existing runtime -context from the bound debug conversation, triggers the Agent backend side -effect synchronously, and skips Dify-side chat/message persistence. +LLM/ReAct loop. Build-chat finalization uses this same streamed path and only +changes the runtime exit policy carried to the backend. """ from __future__ import annotations @@ -41,13 +38,16 @@ from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager from core.app.entities.app_invoke_entities import ( + AGENT_RUNTIME_EXIT_INTENT_ARG, AgentAppGenerateEntity, + AgentRuntimeExitIntent, DifyRunContext, InvokeFrom, UserFrom, ) from core.app.llm.model_access import build_dify_model_access from core.ops.ops_trace_manager import TraceQueueManager +from core.workflow.file_reference import build_file_reference, is_canonical_file_reference from extensions.ext_database import db from models import Account, App, EndUser, Message from models.agent import ( @@ -64,12 +64,68 @@ from services.conversation_service import ConversationService logger = logging.getLogger(__name__) +_REFERENCE_FILE_TRANSFER_METHODS = {"local_file", "tool_file", "datasource_file"} + def _append_prompt_file_mappings(query: str, prompt_file_mappings: Sequence[JsonValue]) -> str: - """Append raw request file references to the backend user prompt.""" - if not prompt_file_mappings: + """Append labeled, prompt-safe file locators to the backend user prompt.""" + prompt_files = _prompt_file_locators(prompt_file_mappings) + if not prompt_files: return query - return f"{query}\n{json.dumps(list(prompt_file_mappings), ensure_ascii=False)}" + payload = json.dumps(prompt_files, ensure_ascii=False, separators=(",", ":")) + return ( + f"{query}\n" + "User provided files: use dify-agent file download with the listed transfer_method and reference/url " + "to get the files and investigate them\n" + f"{payload}" + ) + + +def _prompt_file_locators(prompt_file_mappings: Sequence[JsonValue]) -> list[dict[str, str]]: + locators: list[dict[str, str]] = [] + for file_mapping in prompt_file_mappings: + if not isinstance(file_mapping, Mapping): + continue + locator = _prompt_file_locator(file_mapping) + if locator is not None: + locators.append(locator) + return locators + + +def _prompt_file_locator(file_mapping: Mapping[str, object]) -> dict[str, str] | None: + transfer_method = _string_value(file_mapping, "transfer_method") + if transfer_method == "remote_url": + url = _string_value(file_mapping, "url") or _string_value(file_mapping, "remote_url") + if url is None: + return None + return {"transfer_method": "remote_url", "url": url} + elif transfer_method in _REFERENCE_FILE_TRANSFER_METHODS: + if transfer_method is None: + return None + reference = _canonical_file_reference( + _string_value(file_mapping, "reference") + or _string_value(file_mapping, "upload_file_id") + or _string_value(file_mapping, "file_id") + or _string_value(file_mapping, "id") + ) + if reference is None: + return None + return {"transfer_method": transfer_method, "reference": reference} + else: + return None + + +def _canonical_file_reference(reference: str | None) -> str | None: + if reference is None: + return None + if reference.startswith("dify-file-ref:"): + return reference if is_canonical_file_reference(reference) else None + return build_file_reference(record_id=reference) + + +def _string_value(mapping: Mapping[str, object], key: str) -> str | None: + value = mapping.get(key) + return value if isinstance(value, str) and value else None class AgentAppGenerator(MessageBasedAppGenerator): @@ -120,6 +176,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): model_conf = ModelConfigConverter.convert(app_config) trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id) + agent_runtime_exit_intent = self._resolve_agent_runtime_exit_intent(args) application_generate_entity = AgentAppGenerateEntity( task_id=str(uuid.uuid4()), @@ -149,6 +206,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): agent_config_snapshot_id=agent_config_id, agent_config_version_kind=agent_config_version_kind, agent_runtime_session_snapshot_id=runtime_session_snapshot_id, + agent_runtime_exit_intent=agent_runtime_exit_intent, ) conversation, message = self._init_generate_records(application_generate_entity, conversation) @@ -187,86 +245,6 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from) - def generate_stateless( - self, - *, - app_model: App, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - ) -> Mapping[str, Any]: - """Run one Agent App turn without persisting Dify conversation messages.""" - query = self._require_query(args) - conversation_id = args.get("conversation_id") - if not isinstance(conversation_id, str) or not conversation_id: - raise AgentAppGeneratorError("conversation_id is required") - - agent, agent_config_id, agent_config_version_kind, agent_soul = self._resolve_agent( - app_model, - invoke_from=invoke_from, - draft_type=args.get("draft_type"), - user=user, - ) - runtime_session_snapshot_id = self._runtime_session_snapshot_id( - invoke_from=invoke_from, - snapshot_id=agent_config_id, - ) - - return self._run_stateless( - app_model=app_model, - user=user, - invoke_from=invoke_from, - query=query, - conversation_id=conversation_id, - agent=agent, - agent_config_id=agent_config_id, - agent_config_version_kind=agent_config_version_kind, - agent_soul=agent_soul, - runtime_session_snapshot_id=runtime_session_snapshot_id, - ) - - def _run_stateless( - self, - *, - app_model: App, - user: Account | EndUser, - invoke_from: InvokeFrom, - query: str, - conversation_id: str, - agent: Agent, - agent_config_id: str, - agent_config_version_kind: Literal["snapshot", "draft", "build_draft"], - agent_soul: AgentSoulConfig, - runtime_session_snapshot_id: str | None, - ) -> Mapping[str, Any]: - """Run the Agent backend without creating or updating Dify chat records. - - Build-chat finalization is an action against the Agent backend (for - example, ``dify-agent config push``). It may reuse the active build-chat - runtime snapshot for shell/config context, but the API side must not add - a synthetic user/assistant turn to the debug conversation. - """ - - dify_context = DifyRunContext( - tenant_id=app_model.tenant_id, - app_id=app_model.id, - user_id=user.id, - user_from=UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER, - invoke_from=invoke_from, - ) - self._build_runner(dify_context).run_stateless( - dify_context=dify_context, - agent_id=agent.id, - agent_config_snapshot_id=agent_config_id, - agent_config_version_kind=agent_config_version_kind, - agent_soul=agent_soul, - conversation_id=conversation_id, - query=query, - idempotency_key=str(uuid.uuid4()), - session_scope_snapshot_id=runtime_session_snapshot_id, - ) - return {"result": "success"} - def resume_after_form_submission( self, *, @@ -476,6 +454,7 @@ class AgentAppGenerator(MessageBasedAppGenerator): model_name=application_generate_entity.model_conf.model, queue_manager=queue_manager, session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id, + agent_runtime_exit_intent=application_generate_entity.agent_runtime_exit_intent, ) except GenerateTaskStoppedError: pass @@ -492,6 +471,18 @@ class AgentAppGenerator(MessageBasedAppGenerator): raise AgentAppGeneratorError("query is required") return query.replace("\x00", "") + @staticmethod + def _resolve_agent_runtime_exit_intent(args: Mapping[str, Any]) -> AgentRuntimeExitIntent: + """Resolve API-internal runtime exit policy from controller-owned args. + + Only the private controller-injected "delete" value changes behavior. + Normal chat and resume flows default/fallback to "suspend" so public + payloads and invalid internal values preserve existing semantics. + """ + if args.get(AGENT_RUNTIME_EXIT_INTENT_ARG) == "delete": + return "delete" + return "suspend" + @staticmethod def _build_runner(dify_context: DifyRunContext) -> AgentAppRunner: credentials_provider, _ = build_dify_model_access(dify_context) diff --git a/api/core/app/apps/agent_app/app_runner.py b/api/core/app/apps/agent_app/app_runner.py index 8451ccebd4d..c6546cac569 100644 --- a/api/core/app/apps/agent_app/app_runner.py +++ b/api/core/app/apps/agent_app/app_runner.py @@ -1,15 +1,10 @@ -"""Agent App runner: drive Agent backend turns for both chat and finalize flows. +"""Agent App runner: drive Agent backend turns for chat and finalization flows. Unlike the legacy ``AgentChatAppRunner`` (which runs an in-process ReAct loop), -this runner delegates to the Agent backend and supports two execution modes. - -- Normal chat turns build the run request from the Agent Soul + conversation, - consume backend stream events, republish the assistant answer through the - existing EasyUI chat task pipeline, and save the conversation - ``session_snapshot`` on success for multi-turn continuity (S3). -- Stateless build-finalize turns reuse any prior conversation snapshot only to - construct the backend request, wait synchronously for backend completion, and - intentionally do not persist Dify-side chat records or runtime-session state. +this runner delegates to the Agent backend, consumes the streamed event flow, +republishes the assistant answer through the existing EasyUI chat task +pipeline, and then either saves or retires the conversation-owned runtime +session depending on the turn's exit policy. """ from __future__ import annotations @@ -26,6 +21,7 @@ from dify_agent.protocol import DeferredToolResultsPayload from pydantic import JsonValue from clients.agent_backend import ( + AgentBackendAgentMessageDeltaInternalEvent, AgentBackendDeferredToolCallInternalEvent, AgentBackendError, AgentBackendInternalEventType, @@ -35,7 +31,7 @@ from clients.agent_backend import ( AgentBackendStreamInternalEvent, extract_runtime_layer_specs, ) -from configs import dify_config +from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload from core.app.apps.agent_app.runtime_request_builder import ( AgentAppRuntimeBuildContext, AgentAppRuntimeRequest, @@ -48,8 +44,13 @@ from core.app.apps.agent_app.session_store import ( ) from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom from core.app.apps.exc import GenerateTaskStoppedError -from core.app.entities.app_invoke_entities import DifyRunContext -from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent +from core.app.entities.app_invoke_entities import AgentRuntimeExitIntent, DifyRunContext +from core.app.entities.queue_entities import ( + QueueAgentMessageEvent, + QueueAgentThoughtEvent, + QueueLLMChunkEvent, + QueueMessageEndEvent, +) from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form @@ -59,6 +60,7 @@ from graphon.model_runtime.entities.message_entities import AssistantPromptMessa from models.agent_config_entities import AgentSoulConfig from models.enums import CreatorUserRole from models.model import MessageAgentThought +from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session logger = logging.getLogger(__name__) @@ -135,6 +137,25 @@ def publish_text_delta( queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER) +def publish_agent_message_delta( + *, + queue_manager: AppQueueManager, + model_name: str, + delta: str, + user_query: str | None = None, +) -> None: + """Publish one agent-process text delta through the EasyUI chat pipeline.""" + if not delta: + return + prompt_messages = _prompt_messages_from_query(user_query) + chunk = LLMResultChunk( + model=model_name, + prompt_messages=prompt_messages, + delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=delta)), + ) + queue_manager.publish(QueueAgentMessageEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER) + + def publish_message_end( *, queue_manager: AppQueueManager, @@ -159,7 +180,7 @@ def publish_message_end( class _TextDeltaDebouncer: - """Batch assistant text deltas on stream-event boundaries for final SSE output.""" + """Batch independent model text deltas before agent-message SSE output.""" def __init__(self, *, debounce_seconds: float) -> None: self._debounce_seconds = debounce_seconds @@ -192,7 +213,13 @@ class _TextDeltaDebouncer: class _AgentProcessRecorder: - """Persist Agent v2 thinking/tool process events through the legacy thought model.""" + """Persist Agent v2 process streams through the legacy thought model. + + Thinking and answer rows expose snapshot updates for contiguous model-text + segments. Tool events close currently open text segments so later model text + starts a fresh row instead of replaying content that was already streamed + before the tool. + """ def __init__( self, @@ -206,6 +233,7 @@ class _AgentProcessRecorder: self._queue_manager = queue_manager self._next_position = 1 self._thinking_by_index: dict[int, str] = {} + self._answer_thought_id: str | None = None self._tool_by_index: dict[int, str] = {} self._tool_by_call_id: dict[str, str] = {} self._open_tool_by_name: dict[str, set[str]] = {} @@ -261,6 +289,41 @@ class _AgentProcessRecorder: if part_kind in {"tool-return", "builtin-tool-return"}: self._record_tool_return_part(part) + def append_answer_text(self, content_delta: str) -> None: + if not content_delta: + return + + self._thinking_by_index.clear() + if self._answer_thought_id is None: + self._answer_thought_id = self._create_thought(answer=content_delta) + return + self._update_thought(self._answer_thought_id, answer_delta=content_delta) + + def trim_answer_suffix(self, final_answer: str) -> None: + if not final_answer or self._answer_thought_id is None: + return + + row = db.session.get(MessageAgentThought, self._answer_thought_id) + if row is None: + return + + answer = row.answer or "" + overlap = _suffix_prefix_overlap_length(answer, final_answer) + if overlap == 0: + return + + row.answer = answer[:-overlap] + if _is_empty_answer_only_thought(row): + db.session.delete(row) + self._answer_thought_id = None + db.session.commit() + return + + db.session.commit() + self._queue_manager.publish( + QueueAgentThoughtEvent(agent_thought_id=self._answer_thought_id), PublishFrom.APPLICATION_MANAGER + ) + def _handle_tool_call_event(self, data: dict[str, Any]) -> None: part = data.get("part") if isinstance(part, dict): @@ -281,6 +344,7 @@ class _AgentProcessRecorder: ) def _append_thinking(self, index: int, content_delta: str) -> None: + self._answer_thought_id = None thought_id = self._thinking_by_index.get(index) if thought_id is None: thought_id = self._create_thought(thought=content_delta) @@ -289,6 +353,7 @@ class _AgentProcessRecorder: self._update_thought(thought_id, thought_delta=content_delta) def _record_tool_call_delta(self, index: int, delta: dict[str, Any]) -> None: + self._close_thinking_segments() tool_call_id = _string_or_none(delta.get("tool_call_id")) tool_name = _string_or_none(delta.get("tool_name_delta")) args_delta = delta.get("args_delta") @@ -305,8 +370,10 @@ class _AgentProcessRecorder: tool=tool_name, tool_input_delta=_json_or_text(args_delta), ) + self._remember_tool_thought(index=index, tool_call_id=tool_call_id, tool_name=tool_name, thought_id=thought_id) def _record_tool_call_part(self, index: int, part: dict[str, Any]) -> None: + self._close_thinking_segments() tool_call_id = _string_or_none(part.get("tool_call_id")) tool_name = _string_or_none(part.get("tool_name")) thought_id = self._lookup_tool_thought(index=index, tool_call_id=tool_call_id) @@ -322,8 +389,10 @@ class _AgentProcessRecorder: tool=tool_name, tool_input=_json_or_text(part.get("args")), ) + self._remember_tool_thought(index=index, tool_call_id=tool_call_id, tool_name=tool_name, thought_id=thought_id) def _record_tool_return_part(self, part: dict[str, Any]) -> None: + self._close_thinking_segments() tool_call_id = _string_or_none(part.get("tool_call_id")) tool_name = _string_or_none(part.get("tool_name")) content = part.get("content") @@ -332,6 +401,7 @@ class _AgentProcessRecorder: self._record_tool_observation(tool_call_id=tool_call_id, tool_name=tool_name, observation=content) def _record_tool_observation(self, *, tool_call_id: str | None, tool_name: str | None, observation: Any) -> None: + self._close_thinking_segments() thought_id = self._lookup_observation_thought(tool_call_id=tool_call_id, tool_name=tool_name) if thought_id is None: thought_id = self._create_thought(tool=tool_name) @@ -366,8 +436,17 @@ class _AgentProcessRecorder: for open_thought_ids in self._open_tool_by_name.values(): open_thought_ids.discard(thought_id) + def _close_thinking_segments(self) -> None: + self._thinking_by_index.clear() + self._answer_thought_id = None + def _create_thought( - self, *, thought: str | None = None, tool: str | None = None, tool_input: str | None = None + self, + *, + thought: str | None = None, + answer: str | None = None, + tool: str | None = None, + tool_input: str | None = None, ) -> str: row = MessageAgentThought( message_id=self._message_id, @@ -384,7 +463,7 @@ class _AgentProcessRecorder: message_unit_price=Decimal(0), message_price_unit=Decimal("0.001"), message_files="", - answer="", + answer=answer or "", answer_token=0, answer_unit_price=Decimal(0), answer_price_unit=Decimal("0.001"), @@ -414,6 +493,7 @@ class _AgentProcessRecorder: tool_input: str | None = None, tool_input_delta: str | None = None, observation: str | None = None, + answer_delta: str | None = None, ) -> None: row = db.session.get(MessageAgentThought, thought_id) if row is None: @@ -430,6 +510,8 @@ class _AgentProcessRecorder: row.tool_input = f"{row.tool_input or ''}{tool_input_delta}" if observation is not None: row.observation = observation + if answer_delta: + row.answer = f"{row.answer or ''}{answer_delta}" db.session.commit() self._queue_manager.publish( @@ -468,6 +550,18 @@ def _tool_labels(tool: str | None) -> str: return json.dumps({tool: {"en_US": tool, "zh_Hans": tool}}, ensure_ascii=False) +def _suffix_prefix_overlap_length(text: str, prefix_source: str) -> int: + max_length = min(len(text), len(prefix_source)) + for length in range(max_length, 0, -1): + if text.endswith(prefix_source[:length]): + return length + return 0 + + +def _is_empty_answer_only_thought(row: MessageAgentThought) -> bool: + return not any((row.thought, row.answer, row.tool, row.tool_input, row.observation)) + + class AgentAppRunner: """Runs one Agent App conversation turn against the Agent backend.""" @@ -500,7 +594,9 @@ class AgentAppRunner: model_name: str, queue_manager: AppQueueManager, session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID, + agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend", ) -> None: + preserve_session = agent_runtime_exit_intent == "suspend" scope = self._build_session_scope( dify_context=dify_context, agent_id=agent_id, @@ -522,10 +618,11 @@ class AgentAppRunner: idempotency_key=message_id, stored=stored, message_id=message_id, + suspend_on_exit=preserve_session, ) create_response = self._agent_backend_client.create_run(runtime.request) - terminal, streamed_answer = self._consume_stream( + terminal, process_recorder = self._consume_stream( create_response.run_id, dify_context=dify_context, message_id=message_id, @@ -535,6 +632,9 @@ class AgentAppRunner: ) if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent): + if not preserve_session: + self._mark_session_cleaned(scope=scope, backend_run_id=terminal.run_id) + raise AgentBackendError("Agent App finalization cannot pause for human input.") # ENG-635: the agent asked a human. End this turn with the question and # a conversation-owned HITL form; a form submission resumes the run. self._pause_for_ask_human( @@ -555,70 +655,49 @@ class AgentAppRunner: error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully." raise AgentBackendError(str(error)) - answer = self._extract_answer(terminal.output) - self._publish_terminal_answer( - queue_manager=queue_manager, - model_name=model_name, - answer=answer, - query=query, - streamed_answer=streamed_answer, - usage=_llm_usage_from_agent_backend(terminal.usage), - ) - self._save_session( - scope=scope, - backend_run_id=terminal.run_id, - snapshot=terminal.session_snapshot, - runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), - ) - - def run_stateless( - self, - *, - dify_context: DifyRunContext, - agent_id: str, - agent_config_snapshot_id: str, - agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot", - agent_soul: AgentSoulConfig, - conversation_id: str, - query: str, - idempotency_key: str, - session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID, - ) -> None: - """Run the Agent backend without creating Dify chat message records. - - This path is used by build-chat finalization: the API must trigger the - backend side effects in the existing conversation session, but it must - not persist a synthetic user/assistant turn, update API-side runtime - session rows, or set up HITL state that depends on one. - """ - scope = self._build_session_scope( - dify_context=dify_context, - agent_id=agent_id, - agent_config_snapshot_id=agent_config_snapshot_id, - conversation_id=conversation_id, - session_scope_snapshot_id=session_scope_snapshot_id, - ) - runtime = self._build_runtime( - dify_context=dify_context, - agent_id=agent_id, - agent_config_snapshot_id=agent_config_snapshot_id, - agent_config_version_kind=agent_config_version_kind, - agent_soul=agent_soul, - conversation_id=conversation_id, - query=query, - idempotency_key=idempotency_key, - stored=self._session_store.load_active_session(scope), - message_id=None, - ) - - create_response = self._agent_backend_client.create_run(runtime.request) - status = self._agent_backend_client.wait_run( - create_response.run_id, - timeout_seconds=dify_config.APP_MAX_EXECUTION_TIME, - ) - if status.status != "succeeded": - error = getattr(status, "error", None) or f"Agent backend run ended with status {status.status}." - raise AgentBackendError(str(error)) + answer = self._terminal_output_to_answer(terminal.output) + try: + process_recorder.trim_answer_suffix(answer) + except Exception: + db.session.rollback() + logger.warning( + "Failed to trim Agent App answer text: run_id=%s message_id=%s", + terminal.run_id, + message_id, + exc_info=True, + ) + if preserve_session: + superseded_sessions = self._load_superseded_sessions(scope=scope) + self._publish_terminal_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=answer, + query=query, + usage=_llm_usage_from_agent_backend(terminal.usage), + ) + session_saved = self._save_session( + scope=scope, + backend_run_id=terminal.run_id, + snapshot=terminal.session_snapshot, + runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition), + ) + if session_saved: + self._cleanup_superseded_sessions(superseded_sessions) + else: + # The backend has already accepted a terminal success with + # delete-on-exit semantics. Local publish/persistence errors must + # not keep the API-side session row active, and cleanup failures + # must not replace the original publish/error outcome. + try: + self._publish_terminal_answer( + queue_manager=queue_manager, + model_name=model_name, + answer=answer, + query=query, + usage=_llm_usage_from_agent_backend(terminal.usage), + ) + finally: + self._mark_session_cleaned(scope=scope, backend_run_id=terminal.run_id) def _build_session_scope( self, @@ -654,6 +733,7 @@ class AgentAppRunner: idempotency_key: str, stored: StoredAgentAppSession | None, message_id: str | None, + suspend_on_exit: bool, ) -> AgentAppRuntimeRequest: session_snapshot = stored.session_snapshot if stored is not None else None deferred_tool_results = ( @@ -673,6 +753,7 @@ class AgentAppRunner: idempotency_key=idempotency_key, session_snapshot=session_snapshot, deferred_tool_results=deferred_tool_results, + suspend_on_exit=suspend_on_exit, ) ) @@ -775,46 +856,61 @@ class AgentAppRunner: model_name: str, query: str | None, ): - """Consume backend events while preserving raw recorder granularity. - - Process events are recorded immediately for observability. Only the - final assistant text deltas sent through the EasyUI queue are debounced, - with flushes happening on later stream events or terminal boundaries. - """ + """Consume backend events while preserving raw recorder granularity.""" terminal = None - streamed_answer_parts: list[str] = [] - text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds) process_recorder = _AgentProcessRecorder( dify_context=dify_context, message_id=message_id, queue_manager=queue_manager, ) + text_delta_debouncer = _TextDeltaDebouncer(debounce_seconds=self._text_delta_debounce_seconds) - def flush_pending_text() -> None: + def persist_answer_text(content_delta: str) -> None: + try: + process_recorder.append_answer_text(content_delta) + except Exception: + db.session.rollback() + logger.warning( + "Failed to persist Agent App answer text: run_id=%s message_id=%s", + run_id, + message_id, + exc_info=True, + ) + publish_agent_message_delta( + queue_manager=queue_manager, + model_name=model_name, + delta=content_delta, + user_query=query, + ) + + def flush_pending_agent_message_text() -> None: pending_text = text_delta_debouncer.flush() if pending_text: - publish_text_delta( - queue_manager=queue_manager, - model_name=model_name, - delta=pending_text, - user_query=query, - ) + persist_answer_text(pending_text) for public_event in self._agent_backend_client.stream_events(run_id): if queue_manager.is_stopped(): - flush_pending_text() + flush_pending_agent_message_text() self._cancel_run(run_id) raise GenerateTaskStoppedError() for internal_event in self._event_adapter.adapt(public_event): if queue_manager.is_stopped(): - flush_pending_text() + flush_pending_agent_message_text() self._cancel_run(run_id) raise GenerateTaskStoppedError() if internal_event.type in ( AgentBackendInternalEventType.RUN_STARTED, AgentBackendInternalEventType.STREAM_EVENT, + AgentBackendInternalEventType.AGENT_MESSAGE_DELTA, ): + if isinstance(internal_event, AgentBackendAgentMessageDeltaInternalEvent): + debounced_delta = text_delta_debouncer.push(internal_event.delta) + if debounced_delta: + persist_answer_text(debounced_delta) + continue + if isinstance(internal_event, AgentBackendStreamInternalEvent): + flush_pending_agent_message_text() try: process_recorder.handle_stream_event(internal_event) except Exception: @@ -826,26 +922,15 @@ class AgentAppRunner: internal_event.event_kind, exc_info=True, ) - text_delta = self._extract_stream_text_delta(internal_event) - if text_delta: - streamed_answer_parts.append(text_delta) - debounced_delta = text_delta_debouncer.push(text_delta) - if debounced_delta: - publish_text_delta( - queue_manager=queue_manager, - model_name=model_name, - delta=debounced_delta, - user_query=query, - ) continue continue - flush_pending_text() + flush_pending_agent_message_text() terminal = internal_event break if terminal is not None: break - flush_pending_text() - return terminal, "".join(streamed_answer_parts) + flush_pending_agent_message_text() + return terminal, process_recorder def _cancel_run(self, run_id: str) -> None: try: @@ -867,42 +952,15 @@ class AgentAppRunner: model_name: str, answer: str, query: str | None, - streamed_answer: str, usage: LLMUsage | None, ) -> None: - """Finish a successful streamed turn without duplicating the final text.""" - if not answer and streamed_answer: - answer = streamed_answer - - if not streamed_answer: - publish_text_answer( - queue_manager=queue_manager, - model_name=model_name, - answer=answer, - user_query=query, - usage=usage, - ) - return - - if answer.startswith(streamed_answer): - publish_text_delta( - queue_manager=queue_manager, - model_name=model_name, - delta=answer[len(streamed_answer) :], - user_query=query, - ) - elif answer != streamed_answer: - logger.warning( - "Agent App streamed answer does not match terminal output; " - "using terminal output for message persistence." - ) - - publish_message_end( + """Finish a successful turn from the backend terminal output.""" + publish_text_answer( queue_manager=queue_manager, model_name=model_name, answer=answer, - user_query=query, usage=usage, + user_query=query, ) def _save_session( @@ -914,7 +972,7 @@ class AgentAppRunner: runtime_layer_specs: Any, pending_form_id: str | None = None, pending_tool_call_id: str | None = None, - ) -> None: + ) -> bool: try: self._session_store.save_active_snapshot( scope=scope, @@ -924,6 +982,7 @@ class AgentAppRunner: pending_form_id=pending_form_id, pending_tool_call_id=pending_tool_call_id, ) + return True except Exception: logger.warning( "Failed to persist Agent App conversation session snapshot: " @@ -934,9 +993,91 @@ class AgentAppRunner: scope.agent_id, exc_info=True, ) + return False + + def _load_superseded_sessions(self, *, scope: AgentAppSessionScope) -> list[StoredAgentAppSession]: + try: + stored_sessions = self._session_store.list_active_sessions_for_conversation( + tenant_id=scope.tenant_id, + app_id=scope.app_id, + conversation_id=scope.conversation_id, + ) + except Exception: + logger.warning( + "Failed to load existing Agent App conversation sessions before snapshot save: " + "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s", + scope.tenant_id, + scope.app_id, + scope.conversation_id, + scope.agent_id, + exc_info=True, + ) + return [] + + return [stored for stored in stored_sessions if stored.scope != scope] + + def _cleanup_superseded_sessions(self, stored_sessions: list[StoredAgentAppSession]) -> None: + for stored_session in stored_sessions: + try: + if stored_session.runtime_layer_specs: + payload = AgentBackendSessionCleanupPayload( + session_snapshot=stored_session.session_snapshot, + runtime_layer_specs=stored_session.runtime_layer_specs, + idempotency_key=( + f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:" + f"{stored_session.scope.conversation_id}:{stored_session.scope.agent_id}:" + f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:" + f"superseded-session-cleanup:{stored_session.backend_run_id or 'no-run'}" + ), + metadata={ + "tenant_id": stored_session.scope.tenant_id, + "app_id": stored_session.scope.app_id, + "conversation_id": stored_session.scope.conversation_id, + "agent_id": stored_session.scope.agent_id, + "agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id, + "previous_agent_backend_run_id": stored_session.backend_run_id, + }, + ) + cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json")) + except Exception: + logger.warning( + "Failed to enqueue Agent backend cleanup for superseded Agent App session: " + "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", + stored_session.scope.tenant_id, + stored_session.scope.app_id, + stored_session.scope.conversation_id, + stored_session.scope.agent_id, + stored_session.backend_run_id, + exc_info=True, + ) + + def _mark_session_cleaned( + self, + *, + scope: AgentAppSessionScope, + backend_run_id: str, + ) -> None: + """Best-effort delete-on-exit cleanup for the API-side session row. + + Once the Agent backend reaches a terminal event, cleanup persistence + must not replace the original publish/error outcome for that turn. + """ + try: + self._session_store.mark_cleaned(scope=scope, backend_run_id=backend_run_id) + except Exception: + logger.warning( + "Failed to retire Agent App conversation session after delete-on-exit: " + "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", + scope.tenant_id, + scope.app_id, + scope.conversation_id, + scope.agent_id, + backend_run_id, + exc_info=True, + ) @staticmethod - def _extract_answer(output: JsonValue) -> str: + def _terminal_output_to_answer(output: JsonValue) -> str: """Normalize the backend's terminal output to assistant text. Free-text Agent Apps return a plain string; if a structured output is @@ -954,27 +1095,5 @@ class AgentAppRunner: return json.dumps(output, ensure_ascii=False) return json.dumps(output, ensure_ascii=False) - @staticmethod - def _extract_stream_text_delta(event: AgentBackendStreamInternalEvent) -> str | None: - data = event.data - if not isinstance(data, dict): - return None - - if data.get("event_kind") == "part_delta": - delta = data.get("delta") - if isinstance(delta, dict) and delta.get("part_delta_kind") == "text": - content_delta = delta.get("content_delta") - if isinstance(content_delta, str): - return content_delta - - if data.get("event_kind") == "part_start": - part = data.get("part") - if isinstance(part, dict) and part.get("part_kind") == "text": - content = part.get("content") - if isinstance(content, str): - return content - - return None - __all__ = ["AgentAppRunner", "publish_message_end", "publish_text_answer", "publish_text_delta"] diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py index fb1d054e750..c90ab77d681 100644 --- a/api/core/app/apps/agent_app/runtime_request_builder.py +++ b/api/core/app/apps/agent_app/runtime_request_builder.py @@ -74,6 +74,7 @@ class AgentAppRuntimeBuildContext: session_snapshot: CompositorSessionSnapshot | None = None # ENG-638: set when resuming a chat turn after a submitted ask_human form. deferred_tool_results: DeferredToolResultsPayload | None = None + suspend_on_exit: bool = True @dataclass(frozen=True, slots=True) @@ -163,6 +164,7 @@ class AgentAppRuntimeRequestBuilder: # no frontend-internal {{#…#}} marker ever reaches the model. agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip() or None, + agent_config_version_kind=context.agent_config_version_kind, user_prompt=context.user_query, tools=tool_layers.plugin_tools, core_tools=tool_layers.core_tools, @@ -173,6 +175,7 @@ class AgentAppRuntimeRequestBuilder: shell_config=build_shell_layer_config(agent_soul), session_snapshot=context.session_snapshot, deferred_tool_results=context.deferred_tool_results, + suspend_on_exit=context.suspend_on_exit, idempotency_key=context.idempotency_key, metadata=metadata, ) diff --git a/api/core/app/apps/agent_app/session_store.py b/api/core/app/apps/agent_app/session_store.py index 35213114e2f..7696155a1c4 100644 --- a/api/core/app/apps/agent_app/session_store.py +++ b/api/core/app/apps/agent_app/session_store.py @@ -124,6 +124,41 @@ class AgentAppRuntimeSessionStore: runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), ) + def list_active_sessions_for_conversation( + self, *, tenant_id: str, app_id: str, conversation_id: str + ) -> list[StoredAgentAppSession]: + """List all ACTIVE conversation-owned sessions for lifecycle cleanup.""" + stmt = ( + select(AgentRuntimeSession) + .where( + AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION, + AgentRuntimeSession.tenant_id == tenant_id, + AgentRuntimeSession.app_id == app_id, + AgentRuntimeSession.conversation_id == conversation_id, + AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE, + ) + .order_by(AgentRuntimeSession.updated_at.desc()) + ) + with session_factory.create_session() as session: + rows = session.scalars(stmt).all() + return [ + StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id=row.tenant_id, + app_id=row.app_id, + conversation_id=row.conversation_id or "", + agent_id=row.agent_id, + agent_config_snapshot_id=row.agent_config_snapshot_id, + ), + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + backend_run_id=row.backend_run_id, + runtime_layer_specs=_deserialize_runtime_layer_specs(row.composition_layer_specs), + pending_form_id=row.pending_form_id, + pending_tool_call_id=row.pending_tool_call_id, + ) + for row in rows + ] + def save_active_snapshot( self, *, @@ -134,6 +169,14 @@ class AgentAppRuntimeSessionStore: pending_form_id: str | None = None, pending_tool_call_id: str | None = None, ) -> None: + """Persist the current conversation snapshot and enforce one ACTIVE row. + + Agent App chat treats one conversation as one resumable runtime shell. + Saving the latest snapshot therefore upserts the scoped row back to + ACTIVE and retires any other ACTIVE conversation-owned rows for the + same ``tenant_id + app_id + conversation_id`` so later lookups see a + single active session. + """ if snapshot is None: return snapshot_json = snapshot.model_dump_json() diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 690f23c8302..b5f7515cd9f 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -15,6 +15,8 @@ if TYPE_CHECKING: DIFY_RUN_CONTEXT_KEY = "_dify" +AGENT_RUNTIME_EXIT_INTENT_ARG = "_agent_runtime_exit_intent" +type AgentRuntimeExitIntent = Literal["suspend", "delete"] class UserFrom(StrEnum): @@ -228,6 +230,10 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity): ``agent_runtime_session_snapshot_id`` carries the runtime session scope used to resume or suspend within the same editable config surface. + ``agent_runtime_exit_intent`` is API-internal lifecycle policy for the + Agent backend session after this turn finishes. Normal chat/resume turns + suspend on exit; build-chat finalization deletes the backend runtime. + ``prompt_file_mappings`` preserves the raw request ``files`` array for the Agent backend prompt. These references are appended to the backend prompt text while the stored chat message keeps the user's original query. @@ -237,6 +243,7 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity): agent_config_snapshot_id: str agent_config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot" agent_runtime_session_snapshot_id: str | None = None + agent_runtime_exit_intent: AgentRuntimeExitIntent = "suspend" prompt_file_mappings: Sequence[JsonValue] = Field(default_factory=list) diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index b7b3c8b0005..c4224d67442 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -309,32 +309,20 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat response = self._message_cycle_manager.message_file_to_stream_response(event) if response: yield response - case QueueLLMChunkEvent() | QueueAgentMessageEvent(): + case QueueAgentMessageEvent(): chunk = event.chunk - delta_content = chunk.delta.message.content - if delta_content is None: + delta_text = self._chunk_delta_text(chunk) + if delta_text is None: + continue + yield self._agent_message_to_stream_response( + answer=delta_text, + message_id=self._message_id, + ) + case QueueLLMChunkEvent(): + chunk = event.chunk + delta_text = self._chunk_delta_text(chunk) + if delta_text is None: continue - if isinstance(delta_content, list): - # EasyUI streams text only; structured multimodal chunks contribute their text parts. - delta_text = "" - for content in delta_content: - logger.debug( - "The content type %s in LLM chunk delta message content.: %r", type(content), content - ) - match content: - case TextPromptMessageContent(): - delta_text += content.data - case str(): - delta_text += content # failback to str - case _: - logger.warning( - "Unsupported content type %s in LLM chunk delta message content.: %r", - type(content), - content, - ) - continue - else: - delta_text = delta_content if not self._task_state.llm_result.prompt_messages: self._task_state.llm_result.prompt_messages = chunk.prompt_messages @@ -348,23 +336,16 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat current_content += delta_text self._task_state.llm_result.message.content = current_content - match event: - case QueueLLMChunkEvent(): - # Determine the event type once, on first LLM chunk, and reuse for subsequent chunks - if not hasattr(self, "_precomputed_event_type") or self._precomputed_event_type is None: - self._precomputed_event_type = self._message_cycle_manager.get_message_event_type( - message_id=self._message_id - ) - yield self._message_cycle_manager.message_to_stream_response( - answer=delta_text, - message_id=self._message_id, - event_type=self._precomputed_event_type, - ) - case _: - yield self._agent_message_to_stream_response( - answer=delta_text, - message_id=self._message_id, - ) + # Determine the event type once, on first LLM chunk, and reuse for subsequent chunks + if not hasattr(self, "_precomputed_event_type") or self._precomputed_event_type is None: + self._precomputed_event_type = self._message_cycle_manager.get_message_event_type( + message_id=self._message_id + ) + yield self._message_cycle_manager.message_to_stream_response( + answer=delta_text, + message_id=self._message_id, + event_type=self._precomputed_event_type, + ) case QueueMessageReplaceEvent(): yield self._message_cycle_manager.message_replace_to_stream_response(answer=event.text) case QueuePingEvent(): @@ -376,6 +357,32 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat if self._conversation_name_generate_thread: logger.debug("Conversation name generation running as daemon thread") + @staticmethod + def _chunk_delta_text(chunk: LLMResultChunk) -> str | None: + delta_content = chunk.delta.message.content + if delta_content is None: + return None + if not isinstance(delta_content, list): + return delta_content + + delta_text = "" + # EasyUI streams text only; structured multimodal chunks contribute their text parts. + for content in delta_content: + logger.debug("The content type %s in LLM chunk delta message content.: %r", type(content), content) + match content: + case TextPromptMessageContent(): + delta_text += content.data + case str(): + delta_text += content + case _: + logger.warning( + "Unsupported content type %s in LLM chunk delta message content.: %r", + type(content), + content, + ) + continue + return delta_text + def _save_message(self, *, session: Session, trace_manager: TraceQueueManager | None = None): """ Save message. diff --git a/api/core/workflow/nodes/agent_v2/agent_node.py b/api/core/workflow/nodes/agent_v2/agent_node.py index 41778a0ba50..8e27cf63da4 100644 --- a/api/core/workflow/nodes/agent_v2/agent_node.py +++ b/api/core/workflow/nodes/agent_v2/agent_node.py @@ -16,6 +16,7 @@ from clients.agent_backend import ( AgentBackendRunEventAdapter, AgentBackendRunFailedInternalEvent, AgentBackendRunSucceededInternalEvent, + AgentBackendSessionCleanupPayload, AgentBackendStreamError, AgentBackendStreamInternalEvent, AgentBackendTransportError, @@ -34,6 +35,7 @@ from graphon.node_events import NodeEventBase, NodeRunResult, PauseRequestedEven from graphon.nodes.base.node import Node from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig from services.agent.prompt_mentions import extract_workflow_node_output_selectors +from tasks.agent_backend_session_cleanup_task import cleanup_workflow_agent_runtime_session from .ask_human_hitl import AskHumanFormBuildError, build_ask_human_pause_reason from .ask_human_resume import build_deferred_tool_results, resolve_ask_human_form @@ -609,6 +611,44 @@ class DifyAgentNode(Node[DifyAgentNodeData]): ) -> None: if self._session_store is None: return + stored_session = self._session_store.load_active_session(session_scope) + try: + if stored_session is not None and stored_session.runtime_layer_specs: + payload = AgentBackendSessionCleanupPayload( + session_snapshot=stored_session.session_snapshot, + runtime_layer_specs=stored_session.runtime_layer_specs, + idempotency_key=( + f"{session_scope.tenant_id}:{session_scope.workflow_run_id}:{session_scope.node_id}:" + f"{session_scope.binding_id}:workflow-agent-failure-cleanup:" + f"{stored_session.backend_run_id or 'no-stored-run'}:{backend_run_id}" + ), + metadata={ + "tenant_id": session_scope.tenant_id, + "app_id": session_scope.app_id, + "workflow_id": session_scope.workflow_id, + "workflow_run_id": session_scope.workflow_run_id, + "node_id": session_scope.node_id, + "node_execution_id": session_scope.node_execution_id, + "binding_id": session_scope.binding_id, + "agent_id": session_scope.agent_id, + "agent_config_snapshot_id": session_scope.agent_config_snapshot_id, + "previous_agent_backend_run_id": stored_session.backend_run_id, + "failed_agent_backend_run_id": backend_run_id, + }, + ) + cleanup_workflow_agent_runtime_session.delay(payload.model_dump(mode="json")) + except Exception: + logger.warning( + "Failed to enqueue workflow Agent backend cleanup on agent run failure: " + "tenant_id=%s workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s backend_run_id=%s", + session_scope.tenant_id, + session_scope.workflow_run_id, + session_scope.node_id, + session_scope.binding_id, + session_scope.agent_id, + backend_run_id, + exc_info=True, + ) try: self._session_store.mark_cleaned(scope=session_scope, backend_run_id=backend_run_id) agent_backend = dict(metadata.get("agent_backend") or {}) diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py index 5301da9805d..3afa740e545 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -454,7 +454,7 @@ class WorkflowAgentRuntimeRequestBuilder: def _resolve_prompt_payload_value(cls, value: Any) -> tuple[Any, bool]: # File-valued workflow context must surface as Agent Stub download # mappings so the model can materialize those inputs with - # `dify-agent file download --mapping ...` inside the sandbox. + # `dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL` inside the sandbox. download_mapping = cls._agent_stub_download_mapping(value) if download_mapping is not None: return download_mapping, True @@ -499,7 +499,21 @@ class WorkflowAgentRuntimeRequestBuilder: return mapping.model_dump(mode="json", exclude_none=True) if isinstance(value, Mapping): - mapping = AgentStubFileMapping.model_validate(value) + transfer_method = value.get("transfer_method") + if not isinstance(transfer_method, str): + return None + if transfer_method == "remote_url": + mapping = AgentStubFileMapping( + transfer_method="remote_url", + url=value.get("url") or value.get("remote_url"), + ) + elif transfer_method in {"local_file", "tool_file", "datasource_file"}: + mapping = AgentStubFileMapping( + transfer_method=cast(AgentStubFileTransferMethod, transfer_method), + reference=value.get("reference"), + ) + else: + return None return mapping.model_dump(mode="json", exclude_none=True) except ValidationError: return None @@ -600,11 +614,12 @@ class WorkflowAgentRuntimeRequestBuilder: broad generated schema but fails API-side output type checking. For files produced inside an Agent run, the supported persisted shape is - narrower than every downloadable mapping: the sandbox must upload the - local artifact via ``dify-agent file upload ``, which returns a - ``tool_file`` mapping. ``local_file`` and ``datasource_file`` are valid - for existing file references in workflow context, not for newly produced - Agent output files. + narrower than the full CLI upload stdout: the sandbox must upload the + local artifact via ``dify-agent file upload ``, then use the + returned ``reference`` inside the accepted ``tool_file`` mapping shape + for structured ``final_output``. ``local_file`` and ``datasource_file`` + are valid for existing file references in workflow context, not for + newly produced Agent output files. """ return { "title": "AgentStubFileMapping", @@ -659,9 +674,9 @@ class WorkflowAgentRuntimeRequestBuilder: if output.type == DeclaredOutputType.FILE: file_output_lines.append( f"- `{output.name}`: create the file in the sandbox, run `dify-agent file upload `, " - f"and set `final_output.{output.name}` to the returned AgentStubFileMapping JSON object. " - "Do not call `final_output` before the upload command succeeds. Do not use the local path, " - "filename, URL, or a synthesized/base64-encoded value as the `reference`." + f"then set `final_output.{output.name}` to a `tool_file` mapping using the returned " + f"`reference`. Do not call `final_output` before the upload command succeeds. Do not use " + "the local path, filename, URL, or a synthesized/base64-encoded value as the `reference`." ) elif ( output.type == DeclaredOutputType.ARRAY @@ -670,9 +685,9 @@ class WorkflowAgentRuntimeRequestBuilder: ): file_output_lines.append( f"- `{output.name}`: for every produced file, run `dify-agent file upload ` and set " - f"`final_output.{output.name}` to an array of the returned AgentStubFileMapping JSON objects. " - "Do not call `final_output` before all upload commands succeed. Do not use local paths, filenames, " - "URLs, or synthesized/base64-encoded values as `reference` values." + f"`final_output.{output.name}` to an array of `tool_file` mappings using the returned " + f"`reference` values. Do not call `final_output` before all upload commands succeed. Do not use " + "local paths, filenames, URLs, or synthesized/base64-encoded values as `reference` values." ) if not file_output_lines: return None @@ -680,8 +695,12 @@ class WorkflowAgentRuntimeRequestBuilder: return "\n".join( [ "When filling file outputs, do not return a local filesystem path directly.", - "Upload each sandbox-local file through the Agent Stub CLI first. Copy the JSON printed by " - "`dify-agent file upload ` verbatim into the final output; never invent the `reference` value.", + "Upload each sandbox-local file through the Agent Stub CLI first. For structured `final_output`, use " + "only the accepted file-mapping shape and the returned `reference`; never invent the `reference` " + "value.", + "If you are replying to the user in natural language and want them to open or download the produced " + "file, include the returned `download_url` in that reply instead of copying it into structured " + "`final_output` unless the schema explicitly asks for it.", *file_output_lines, ] ) @@ -779,6 +798,26 @@ def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBa def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig: + weights = None + if retrieval.weights is not None: + # The dify-agent runtime payload only consumes the nested vector/keyword + # settings; ``weight_type`` is an API-side authoring detail and must not + # leak into the inner request shape. + weights = ( + cast( + dict[str, Any], + { + key: value + for key, value in { + "vector_setting": retrieval.weights.vector_setting, + "keyword_setting": retrieval.weights.keyword_setting, + }.items() + if value is not None + }, + ) + or None + ) + return DifyKnowledgeRetrievalConfig( mode=retrieval.mode, top_k=retrieval.top_k, @@ -791,9 +830,7 @@ def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> Dif ) if retrieval.reranking_model is not None else None, - weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True)) - if retrieval.weights is not None - else None, + weights=weights, model=_knowledge_model_config(retrieval.model), ) diff --git a/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py b/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py index 809f63b556f..c3a7c23a56c 100644 --- a/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py +++ b/api/core/workflow/nodes/agent_v2/session_cleanup_layer.py @@ -1,11 +1,11 @@ +"""Workflow terminal layer that retires Agent backend sessions asynchronously.""" + from __future__ import annotations import logging from typing import override -from clients.agent_backend import AgentBackendError, AgentBackendRunClient, AgentBackendRunRequestBuilder -from clients.agent_backend.factory import create_agent_backend_run_client -from configs import dify_config +from clients.agent_backend import AgentBackendSessionCleanupPayload from core.workflow.system_variables import SystemVariableKey, get_system_text from graphon.graph_engine.layers import GraphEngineLayer from graphon.graph_events import ( @@ -15,57 +15,23 @@ from graphon.graph_events import ( GraphRunPartialSucceededEvent, GraphRunSucceededEvent, ) +from tasks.agent_backend_session_cleanup_task import cleanup_workflow_agent_runtime_session from .session_store import StoredWorkflowAgentSession, WorkflowAgentRuntimeSessionStore logger = logging.getLogger(__name__) -# Upper bound on how long a cleanup-only run is allowed to settle before the -# layer gives up and leaves the row ACTIVE so it can be retried later. Cleanup -# work is mostly local agent-backend bookkeeping (no LLM inference), so 30s is -# generous; a hung backend should never block workflow termination beyond this. -_CLEANUP_WAIT_TIMEOUT_SECONDS = 30.0 - - class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): - """Retires workflow Agent session snapshots when a workflow reaches a terminal state. + """Retire workflow-owned Agent runtime sessions when the workflow ends. - Implementation notes — there are two failure modes the cleanup path has to - avoid simultaneously: - - 1. The agenton compositor on the agent-backend side validates the cleanup - request's session snapshot against the replayed composition before - running any lifecycle hook. If the snapshot's layer names diverge from - the composition, the run fails asynchronously with ``run_failed`` — but - the initial ``POST /runs`` already returned 202, so the API side has no - visibility of the failure unless it waits for terminal status. The - ``runtime_layer_specs`` persistence in A.1–A.4 plus the - ``_filter_snapshot_to_specs`` shape in ``build_cleanup_request`` keeps - the two name lists in sync. - - 2. The current agent backend's ``runner.py::_run_agent`` always invokes - ``run.get_layer("llm")`` and the structured-output / history validators - before exiting any slot — there is no ``purpose: "cleanup"`` branch - yet. A truly cleanup-only request (no LLM layer) therefore still - crashes inside the runner with ``Layer 'llm' is not defined in this - compositor run.``. Until the backend grows a cleanup-only purpose, - this layer **does not issue an HTTP cleanup run**: it simply retires - the local snapshot row so stale state cannot be re-resumed, and lets - the agent backend's own retention TTL release the suspended layers. - - The HTTP-cleanup machinery (``build_cleanup_request`` + ``wait_run``) is - intentionally still wired into the request builder + integration tests so - that when the agent backend supports cleanup runs we can flip the switch - here with a one-line change (see ``_HTTP_CLEANUP_SUPPORTED``). + Workflow termination is a product-lifecycle boundary: once the run reaches a + terminal graph event, the local session row must no longer be resumable. The + actual Agent backend cleanup is therefore dispatched asynchronously with the + persisted snapshot/specs payload, while the local row is marked CLEANED + immediately afterwards regardless of enqueue outcome. """ - # Flip to True once dify-agent's runner has a ``purpose=cleanup`` branch - # that skips the LLM/output/user-prompt invariants. Until then we only - # update the local row; the spec list is still persisted so the future - # HTTP cleanup path has everything it needs. - _HTTP_CLEANUP_SUPPORTED: bool = False - _TERMINAL_EVENTS = ( GraphRunSucceededEvent, GraphRunPartialSucceededEvent, @@ -73,19 +39,9 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): GraphRunAbortedEvent, ) - def __init__( - self, - *, - session_store: WorkflowAgentRuntimeSessionStore, - request_builder: AgentBackendRunRequestBuilder, - agent_backend_client: AgentBackendRunClient | None, - cleanup_wait_timeout_seconds: float = _CLEANUP_WAIT_TIMEOUT_SECONDS, - ) -> None: + def __init__(self, *, session_store: WorkflowAgentRuntimeSessionStore) -> None: super().__init__() self._session_store = session_store - self._request_builder = request_builder - self._agent_backend_client = agent_backend_client - self._cleanup_wait_timeout_seconds = cleanup_wait_timeout_seconds @override def on_graph_start(self) -> None: @@ -112,136 +68,59 @@ class WorkflowAgentSessionCleanupLayer(GraphEngineLayer): def _cleanup_session(self, stored_session: StoredWorkflowAgentSession) -> None: scope = stored_session.scope - if not self._HTTP_CLEANUP_SUPPORTED: - # Agent backend has no cleanup-only run mode yet (see class - # docstring). Retire the local row so future re-entries do not - # resume from stale state, and let the backend's retention TTL - # release the suspended layers on its own schedule. - logger.info( - "Workflow Agent session retired locally; HTTP cleanup is disabled " - "until the agent backend supports a cleanup-only run mode. " - "workflow_run_id=%s node_id=%s binding_id=%s agent_id=%s previous_run_id=%s", + try: + if stored_session.runtime_layer_specs: + payload = AgentBackendSessionCleanupPayload( + session_snapshot=stored_session.session_snapshot, + runtime_layer_specs=stored_session.runtime_layer_specs, + idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup", + metadata={ + "tenant_id": scope.tenant_id, + "app_id": scope.app_id, + "workflow_id": scope.workflow_id, + "workflow_run_id": scope.workflow_run_id, + "node_id": scope.node_id, + "node_execution_id": scope.node_execution_id, + "binding_id": scope.binding_id, + "agent_id": scope.agent_id, + "agent_config_snapshot_id": scope.agent_config_snapshot_id, + "previous_agent_backend_run_id": stored_session.backend_run_id, + }, + ) + cleanup_workflow_agent_runtime_session.delay(payload.model_dump(mode="json")) + else: + logger.warning( + "Skipping workflow Agent backend cleanup enqueue: no runtime_layer_specs persisted. " + "workflow_run_id=%s node_id=%s agent_id=%s", + scope.workflow_run_id, + scope.node_id, + scope.agent_id, + ) + except Exception: + logger.warning( + "Failed to enqueue workflow Agent backend cleanup: " + "workflow_run_id=%s node_id=%s agent_id=%s previous_run_id=%s", scope.workflow_run_id, scope.node_id, - scope.binding_id, scope.agent_id, stored_session.backend_run_id, - ) - self._session_store.mark_cleaned(scope=scope, backend_run_id=stored_session.backend_run_id) - return - - if self._agent_backend_client is None: - # HTTP cleanup was enabled by the caller but no client was wired - # in (e.g. the API runs without AGENT_BACKEND_BASE_URL configured). - # Leave the row ACTIVE so an operator restart with proper config - # can drive the cleanup; do not silently retire it. - logger.warning( - "Skipping Agent backend cleanup: HTTP cleanup is enabled but no agent " - "backend client is wired in. workflow_run_id=%s node_id=%s agent_id=%s", - scope.workflow_run_id, - scope.node_id, - scope.agent_id, - ) - return - - if not stored_session.runtime_layer_specs: - # Sessions persisted before A.1 landed do not carry the spec list, - # so we cannot replay a valid cleanup composition. Leave the row - # ACTIVE and warn so the absence shows up in observability rather - # than being silently swallowed by a doomed cleanup run. - logger.warning( - "Skipping Agent backend cleanup: no runtime_layer_specs persisted. " - "workflow_run_id=%s node_id=%s agent_id=%s", - scope.workflow_run_id, - scope.node_id, - scope.agent_id, - ) - return - - request = self._request_builder.build_cleanup_request( - session_snapshot=stored_session.session_snapshot, - runtime_layer_specs=stored_session.runtime_layer_specs, - idempotency_key=f"{scope.workflow_run_id}:{scope.node_id}:{scope.binding_id}:agent-session-cleanup", - metadata={ - "tenant_id": scope.tenant_id, - "app_id": scope.app_id, - "workflow_id": scope.workflow_id, - "workflow_run_id": scope.workflow_run_id, - "node_id": scope.node_id, - "node_execution_id": scope.node_execution_id, - "binding_id": scope.binding_id, - "agent_id": scope.agent_id, - "agent_config_snapshot_id": scope.agent_config_snapshot_id, - "previous_agent_backend_run_id": stored_session.backend_run_id, - }, - ) - try: - response = self._agent_backend_client.create_run(request) - except AgentBackendError: - logger.warning( - "Agent backend session cleanup request failed: workflow_run_id=%s node_id=%s agent_id=%s", - scope.workflow_run_id, - scope.node_id, - scope.agent_id, exc_info=True, ) - return - - try: - status_response = self._agent_backend_client.wait_run( - response.run_id, timeout_seconds=self._cleanup_wait_timeout_seconds - ) - except AgentBackendError: - logger.warning( - "Agent backend session cleanup wait_run failed: " - "workflow_run_id=%s node_id=%s agent_id=%s cleanup_run_id=%s", - scope.workflow_run_id, - scope.node_id, - scope.agent_id, - response.run_id, - exc_info=True, - ) - return - - if status_response.status != "succeeded": - logger.warning( - "Agent backend session cleanup did not succeed: status=%s error=%s " - "workflow_run_id=%s node_id=%s agent_id=%s cleanup_run_id=%s", - status_response.status, - status_response.error, - scope.workflow_run_id, - scope.node_id, - scope.agent_id, - response.run_id, - ) - return - - self._session_store.mark_cleaned(scope=scope, backend_run_id=response.run_id) + finally: + try: + self._session_store.mark_cleaned(scope=scope, backend_run_id=stored_session.backend_run_id) + except Exception: + logger.warning( + "Failed to retire workflow Agent runtime session after cleanup enqueue: " + "workflow_run_id=%s node_id=%s agent_id=%s previous_run_id=%s", + scope.workflow_run_id, + scope.node_id, + scope.agent_id, + stored_session.backend_run_id, + exc_info=True, + ) def build_workflow_agent_session_cleanup_layer() -> WorkflowAgentSessionCleanupLayer: - """Wire the cleanup layer with the standard production dependencies. - - The agent backend client is constructed only when ``AGENT_BACKEND_BASE_URL`` - is configured (or the deterministic fake is explicitly enabled). When - neither is set — for example unit tests that bring up the workflow runner - without an Agent node — we pass ``None`` so the layer stays harmless. With - ``_HTTP_CLEANUP_SUPPORTED = False`` the local-retire branch never touches - the client anyway, but keeping it ``None`` avoids importing httpx and lets - test harnesses skip backend configuration. - """ - agent_backend_client: AgentBackendRunClient | None - if dify_config.AGENT_BACKEND_USE_FAKE or dify_config.AGENT_BACKEND_BASE_URL: - agent_backend_client = create_agent_backend_run_client( - base_url=dify_config.AGENT_BACKEND_BASE_URL, - use_fake=dify_config.AGENT_BACKEND_USE_FAKE, - fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, - ) - else: - agent_backend_client = None - - return WorkflowAgentSessionCleanupLayer( - session_store=WorkflowAgentRuntimeSessionStore(), - request_builder=AgentBackendRunRequestBuilder(), - agent_backend_client=agent_backend_client, - ) + """Wire the cleanup layer with the standard workflow-owned session store.""" + return WorkflowAgentSessionCleanupLayer(session_store=WorkflowAgentRuntimeSessionStore()) diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py index d256ad96cf0..72eec3f1c7d 100644 --- a/api/fields/conversation_fields.py +++ b/api/fields/conversation_fields.py @@ -110,6 +110,7 @@ class AgentThought(ResponseModel): message_id: str position: int thought: str | None = None + answer: str | None = None tool: str | None = None tool_labels: JSONValue tool_input: str | None = None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 23e02fac98c..7822f330d84 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -14626,6 +14626,7 @@ Legacy Chat App model config used only for follow-up question generation. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| answer | string | | No | | chain_id | string | | No | | created_at | integer | | No | | files | [ string ] | | Yes | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 590ee66011e..7680ac77111 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -2335,6 +2335,7 @@ Retrieve the list of available models by type. Primarily used to query `text-emb | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| answer | string | | No | | chain_id | string | | No | | created_at | integer | | No | | files | [ string ] | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index 710fc15cd3c..10cc7a831ad 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -936,6 +936,7 @@ Returns Server-Sent Events stream. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| answer | string | | No | | chain_id | string | | No | | created_at | integer | | No | | files | [ string ] | | Yes | diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py index 15d257f474a..5f42bffe3ff 100644 --- a/api/services/agent/prompt_mentions.py +++ b/api/services/agent/prompt_mentions.py @@ -318,9 +318,10 @@ def _format_output_mention(output: DeclaredOutputConfig) -> str: if output.type == DeclaredOutputType.FILE: return ( f"{output.name} (file output; create the file locally, run " - f"`dify-agent file upload `, then copy the returned AgentStubFileMapping JSON " - f"as final_output.{output.name}; do not call final_output before upload succeeds, and do not use " - "the local path, filename, URL, or a synthesized dify-file-ref as the reference)" + f"`dify-agent file upload `, then set final_output.{output.name} to a `tool_file` mapping " + f"using the returned `reference`; if replying to the user in natural language, use the returned " + f"`download_url`; do not call final_output before upload succeeds, and do not use the local path, " + "filename, URL, or a synthesized dify-file-ref as the reference)" ) if ( output.type == DeclaredOutputType.ARRAY @@ -329,9 +330,10 @@ def _format_output_mention(output: DeclaredOutputConfig) -> str: ): return ( f"{output.name} (array[file] output; upload each produced file with " - f"`dify-agent file upload `, then copy the returned AgentStubFileMapping JSON objects " - f"as final_output.{output.name}; do not call final_output before all uploads succeed, and do not use " - "local paths, filenames, URLs, or synthesized dify-file-ref values as references)" + f"`dify-agent file upload `, then set final_output.{output.name} to `tool_file` mappings " + f"using the returned `reference` values; if replying to the user in natural language, use the returned " + f"`download_url`; do not call final_output before all uploads succeed, and do not use local paths, " + "filenames, URLs, or synthesized dify-file-ref values as references)" ) return f"{output.name} ({output.type.value})" diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index de76b3c4eb2..33fd372398b 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -1,9 +1,12 @@ +import logging from typing import Any, TypedDict from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError +from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload from constants.model_template import default_app_templates +from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore from core.app.entities.app_invoke_entities import InvokeFrom from libs.datetime_utils import naive_utc_now from libs.helper import to_timestamp @@ -38,6 +41,9 @@ from services.app_service import AppService, CreateAppParams from services.enterprise.enterprise_service import EnterpriseService from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload from services.feature_service import FeatureService +from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session + +logger = logging.getLogger(__name__) class AgentReferencingWorkflow(TypedDict): @@ -603,7 +609,15 @@ class AgentRosterService: def refresh_agent_app_debug_conversation_id( self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True ) -> str: - """Start a new console debug conversation for the current Agent App editor.""" + """Start a new console debug conversation for the current Agent App editor. + + If this account already has a debug conversation mapping, the previous + conversation is abandoned first: any ACTIVE conversation-owned Agent + runtime sessions for that old conversation are sent through best-effort + backend cleanup and then retired locally even when enqueueing fails. + The debug mapping is then repointed to the freshly created + conversation. + """ agent = self._session.scalar( select(Agent).where( @@ -643,6 +657,16 @@ class AgentRosterService: ) ) else: + previous_app_id = mapping.app_id + previous_conversation_id = mapping.conversation_id + if previous_conversation_id: + self._cleanup_debug_conversation_runtime_sessions( + tenant_id=tenant_id, + agent_id=agent_id, + account_id=account_id, + app_id=previous_app_id or backing_app_id, + conversation_id=previous_conversation_id, + ) mapping.app_id = backing_app_id mapping.conversation_id = conversation_id self._session.flush() @@ -650,6 +674,84 @@ class AgentRosterService: self._session.commit() return conversation_id + def _cleanup_debug_conversation_runtime_sessions( + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + app_id: str, + conversation_id: str, + ) -> None: + session_store = AgentAppRuntimeSessionStore() + try: + stored_sessions = session_store.list_active_sessions_for_conversation( + tenant_id=tenant_id, + app_id=app_id, + conversation_id=conversation_id, + ) + except Exception: + logger.warning( + "Failed to load Agent App runtime sessions for debug conversation refresh: " + "tenant_id=%s app_id=%s conversation_id=%s", + tenant_id, + app_id, + conversation_id, + exc_info=True, + ) + return + + for stored_session in stored_sessions: + try: + if stored_session.runtime_layer_specs: + payload = AgentBackendSessionCleanupPayload( + session_snapshot=stored_session.session_snapshot, + runtime_layer_specs=stored_session.runtime_layer_specs, + idempotency_key=( + f"{tenant_id}:{agent_id}:{account_id}:{conversation_id}:debug-session-cleanup:" + f"{stored_session.scope.agent_id}:" + f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:" + f"{stored_session.backend_run_id or 'no-run'}" + ), + metadata={ + "tenant_id": stored_session.scope.tenant_id, + "app_id": stored_session.scope.app_id, + "conversation_id": stored_session.scope.conversation_id, + "agent_id": stored_session.scope.agent_id, + "agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id, + "previous_agent_backend_run_id": stored_session.backend_run_id, + }, + ) + cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json")) + except Exception: + logger.warning( + "Failed to enqueue Agent backend cleanup for debug conversation refresh: " + "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", + stored_session.scope.tenant_id, + stored_session.scope.app_id, + stored_session.scope.conversation_id, + stored_session.scope.agent_id, + stored_session.backend_run_id, + exc_info=True, + ) + finally: + try: + session_store.mark_cleaned( + scope=stored_session.scope, + backend_run_id=stored_session.backend_run_id, + ) + except Exception: + logger.warning( + "Failed to retire Agent App runtime session for debug conversation refresh: " + "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", + stored_session.scope.tenant_id, + stored_session.scope.app_id, + stored_session.scope.conversation_id, + stored_session.scope.agent_id, + stored_session.backend_run_id, + exc_info=True, + ) + def load_or_create_agent_app_debug_conversation_ids_by_agent_id( self, *, tenant_id: str, agents: list[Agent], account_id: str ) -> dict[str, str]: diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 940cab5f678..7724555b615 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -40,35 +40,6 @@ if TYPE_CHECKING: class AppGenerateService: - @classmethod - @trace_span(AppGenerateHandler) - def generate_stateless_agent_app( - cls, - *, - app_model: App, - user: Account | EndUser, - args: Mapping[str, Any], - invoke_from: InvokeFrom, - ): - """Run build-chat finalization as a blocking, non-SSE Agent App action. - - This is the service entry point for the Agent build-chat finalize flow. - It applies the same tracing, quota, and rate-limit guardrails as normal - app generation, but invokes the Agent App generator in stateless mode: - the call waits synchronously for Agent backend completion, triggers only - the backend side effect, and does not create Dify chat/message records. - """ - return cls._run_with_guardrails( - app_model=app_model, - streaming=False, - action=lambda _rate_limit, _request_id: AgentAppGenerator().generate_stateless( - app_model=app_model, - user=user, - args=args, - invoke_from=invoke_from, - ), - ) - @staticmethod def _build_streaming_task_on_subscribe(start_task: Callable[[], None]) -> Callable[[], None]: """ diff --git a/api/services/conversation_service.py b/api/services/conversation_service.py index 7c3b8d451c5..6ab9c5a8a2b 100644 --- a/api/services/conversation_service.py +++ b/api/services/conversation_service.py @@ -6,7 +6,9 @@ from typing import Any from sqlalchemy import asc, desc, func, or_, select from sqlalchemy.orm import Session +from clients.agent_backend import AgentBackendSessionCleanupPayload from configs import dify_config +from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore from core.app.entities.app_invoke_entities import InvokeFrom from core.llm_generator.llm_generator import LLMGenerator from factories import variable_factory @@ -22,6 +24,7 @@ from services.errors.conversation import ( LastConversationNotExistsError, ) from services.errors.message import MessageNotExistsError +from tasks.agent_backend_session_cleanup_task import cleanup_conversation_agent_runtime_session from tasks.delete_conversation_task import delete_conversation_related_data logger = logging.getLogger(__name__) @@ -186,10 +189,23 @@ class ConversationService: """ Delete a conversation only if it belongs to the given user and app context. + Before removing the conversation row, this best-effort lifecycle path + enumerates any ACTIVE conversation-owned Agent backend runtime sessions, + enqueues asynchronous backend cleanup for rows with persisted runtime + layer specs, and then retires the local session rows even if enqueueing + fails. Conversation deletion and related-data cleanup scheduling still + proceed when that lifecycle bookkeeping only partially succeeds. + Raises: ConversationNotExistsError: When the conversation is not visible to the current user. """ conversation = cls.get_conversation(app_model, conversation_id, user, session=session) + session_store = AgentAppRuntimeSessionStore() + stored_sessions = session_store.list_active_sessions_for_conversation( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + conversation_id=conversation.id, + ) try: logger.info( @@ -197,15 +213,66 @@ class ConversationService: app_model.name, conversation_id, ) + for stored_session in stored_sessions: + try: + if stored_session.runtime_layer_specs: + payload = AgentBackendSessionCleanupPayload( + session_snapshot=stored_session.session_snapshot, + runtime_layer_specs=stored_session.runtime_layer_specs, + idempotency_key=( + f"{stored_session.scope.tenant_id}:{stored_session.scope.app_id}:" + f"{stored_session.scope.conversation_id}:agent-runtime-session-cleanup:" + f"{stored_session.scope.agent_id}:" + f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:" + f"{stored_session.backend_run_id or 'no-run'}" + ), + metadata={ + "tenant_id": stored_session.scope.tenant_id, + "app_id": stored_session.scope.app_id, + "conversation_id": stored_session.scope.conversation_id, + "agent_id": stored_session.scope.agent_id, + "agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id, + "previous_agent_backend_run_id": stored_session.backend_run_id, + }, + ) + cleanup_conversation_agent_runtime_session.delay(payload.model_dump(mode="json")) + except Exception: + logger.warning( + "Failed to enqueue Agent backend cleanup for conversation deletion: " + "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", + stored_session.scope.tenant_id, + stored_session.scope.app_id, + stored_session.scope.conversation_id, + stored_session.scope.agent_id, + stored_session.backend_run_id, + exc_info=True, + ) + finally: + try: + session_store.mark_cleaned( + scope=stored_session.scope, + backend_run_id=stored_session.backend_run_id, + ) + except Exception: + logger.warning( + "Failed to retire Agent App runtime session for conversation deletion: " + "tenant_id=%s app_id=%s conversation_id=%s agent_id=%s backend_run_id=%s", + stored_session.scope.tenant_id, + stored_session.scope.app_id, + stored_session.scope.conversation_id, + stored_session.scope.agent_id, + stored_session.backend_run_id, + exc_info=True, + ) session.delete(conversation) session.commit() delete_conversation_related_data.delay(conversation.id) - except Exception as e: + except Exception: session.rollback() - raise e + raise @classmethod def get_conversational_variable( diff --git a/api/tasks/agent_backend_session_cleanup_task.py b/api/tasks/agent_backend_session_cleanup_task.py new file mode 100644 index 00000000000..2c799bda97c --- /dev/null +++ b/api/tasks/agent_backend_session_cleanup_task.py @@ -0,0 +1,68 @@ +"""Celery tasks that execute Agent backend lifecycle-only session cleanup.""" + +from __future__ import annotations + +import logging + +from celery import shared_task + +from clients.agent_backend.factory import create_agent_backend_run_client +from clients.agent_backend.request_builder import AgentBackendRunRequestBuilder +from clients.agent_backend.session_cleanup import ( + AgentBackendSessionCleanupPayload, + cleanup_agent_backend_session, +) +from configs import dify_config + +logger = logging.getLogger(__name__) + + +def _create_agent_backend_client(): + if not (dify_config.AGENT_BACKEND_USE_FAKE or dify_config.AGENT_BACKEND_BASE_URL): + return None + return create_agent_backend_run_client( + base_url=dify_config.AGENT_BACKEND_BASE_URL, + use_fake=dify_config.AGENT_BACKEND_USE_FAKE, + fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO, + ) + + +def _run_cleanup_task(payload_dict: dict[str, object]) -> None: + payload = AgentBackendSessionCleanupPayload.model_validate(payload_dict) + result = cleanup_agent_backend_session( + payload=payload, + client=_create_agent_backend_client(), + request_builder=AgentBackendRunRequestBuilder(), + ) + if result.status == "succeeded": + return + + log_fields = { + "tenant_id": payload.metadata.get("tenant_id"), + "app_id": payload.metadata.get("app_id"), + "workflow_run_id": payload.metadata.get("workflow_run_id"), + "node_id": payload.metadata.get("node_id"), + "conversation_id": payload.metadata.get("conversation_id"), + "agent_id": payload.metadata.get("agent_id"), + "previous_agent_backend_run_id": payload.metadata.get("previous_agent_backend_run_id"), + "failed_agent_backend_run_id": payload.metadata.get("failed_agent_backend_run_id"), + "cleanup_run_id": result.cleanup_run_id, + "reason": result.reason, + } + if result.status == "skipped": + logger.info("Agent backend session cleanup skipped: %s", log_fields) + return + + logger.warning("Agent backend session cleanup failed: %s", log_fields) + + +@shared_task(queue="workflow_storage") +def cleanup_workflow_agent_runtime_session(payload_dict: dict[str, object]) -> None: + """Run one workflow-owned Agent backend cleanup payload.""" + _run_cleanup_task(payload_dict) + + +@shared_task(queue="conversation") +def cleanup_conversation_agent_runtime_session(payload_dict: dict[str, object]) -> None: + """Run one conversation-owned Agent backend cleanup payload.""" + _run_cleanup_task(payload_dict) diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index a4fb6d57207..2010deb4a80 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -5,17 +5,25 @@ from typing import Any, cast import click import sqlalchemy as sa +from agenton.compositor import CompositorSessionSnapshot from celery import shared_task +from dify_agent.protocol import RuntimeLayerSpec +from pydantic import JsonValue, TypeAdapter from sqlalchemy import delete, select from sqlalchemy.engine import CursorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import sessionmaker +from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload from configs import dify_config from core.db.session_factory import session_factory from extensions.ext_database import db from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage +from libs.datetime_utils import naive_utc_now from models import ( + AgentRuntimeSession, + AgentRuntimeSessionOwnerType, + AgentRuntimeSessionStatus, ApiToken, AppAnnotationHitHistory, AppAnnotationSetting, @@ -50,8 +58,13 @@ from models.workflow import ( ) from repositories.factory import DifyAPIRepositoryFactory from services.api_token_service import ApiTokenCache +from tasks.agent_backend_session_cleanup_task import ( + cleanup_conversation_agent_runtime_session, + cleanup_workflow_agent_runtime_session, +) logger = logging.getLogger(__name__) +_RUNTIME_LAYER_SPECS_ADAPTER: TypeAdapter[list[RuntimeLayerSpec]] = TypeAdapter(list[RuntimeLayerSpec]) @shared_task(queue="app_deletion", bind=True, max_retries=3) @@ -59,6 +72,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): logger.info(click.style(f"Start deleting app and related data: {tenant_id}:{app_id}", fg="green")) start_at = time.perf_counter() try: + _cleanup_active_agent_runtime_sessions_for_app(tenant_id, app_id) # Delete related data _delete_app_model_configs(tenant_id, app_id) _delete_app_site(tenant_id, app_id) @@ -99,6 +113,143 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): raise self.retry(exc=e, countdown=60) # Retry after 60 seconds +def _cleanup_active_agent_runtime_sessions_for_app(tenant_id: str, app_id: str, *, batch_size: int = 100) -> None: + """Best-effort fan-out for ACTIVE Agent runtime sessions during app deletion. + + App deletion must not block on synchronous Agent backend lifecycle work, so + this helper scans ACTIVE ``agent_runtime_sessions`` rows in batches, + dispatches owner-specific cleanup tasks only when enough persisted data + exists to replay a lifecycle-only run, and then marks each visited row + ``CLEANED`` locally regardless of enqueue outcome. The local retirement is + the contract that lets the rest of app deletion continue even when backend + cleanup dispatch is skipped or fails. + """ + if batch_size <= 0: + raise ValueError("batch_size must be positive") + + while True: + with session_factory.create_session() as session: + row_ids = session.scalars( + select(AgentRuntimeSession.id) + .where( + AgentRuntimeSession.tenant_id == tenant_id, + AgentRuntimeSession.app_id == app_id, + AgentRuntimeSession.status == AgentRuntimeSessionStatus.ACTIVE, + ) + .order_by(AgentRuntimeSession.updated_at.asc()) + .limit(batch_size) + ).all() + + if not row_ids: + return + + retired_count = 0 + for row_id in row_ids: + with session_factory.create_session() as session: + row = session.get(AgentRuntimeSession, row_id) + if row is None or row.status != AgentRuntimeSessionStatus.ACTIVE: + retired_count += 1 + continue + + try: + payload = _build_agent_runtime_session_cleanup_payload(row) + if payload is not None: + _enqueue_agent_runtime_session_cleanup(row=row, payload=payload) + except Exception: + logger.warning( + "Failed to enqueue Agent backend cleanup during app deletion: " + "tenant_id=%s app_id=%s owner_type=%s conversation_id=%s workflow_run_id=%s " + "node_id=%s agent_id=%s backend_run_id=%s", + row.tenant_id, + row.app_id, + row.owner_type, + row.conversation_id, + row.workflow_run_id, + row.node_id, + row.agent_id, + row.backend_run_id, + exc_info=True, + ) + finally: + try: + row.status = AgentRuntimeSessionStatus.CLEANED + row.cleaned_at = naive_utc_now() + session.commit() + retired_count += 1 + except Exception: + session.rollback() + logger.warning( + "Failed to retire Agent runtime session during app deletion: " + "tenant_id=%s app_id=%s owner_type=%s conversation_id=%s workflow_run_id=%s " + "node_id=%s agent_id=%s backend_run_id=%s", + row.tenant_id, + row.app_id, + row.owner_type, + row.conversation_id, + row.workflow_run_id, + row.node_id, + row.agent_id, + row.backend_run_id, + exc_info=True, + ) + + if retired_count == 0: + logger.warning( + "Failed to retire any active Agent runtime sessions during app deletion: tenant_id=%s app_id=%s", + tenant_id, + app_id, + ) + return + + +def _build_agent_runtime_session_cleanup_payload( + row: AgentRuntimeSession, +) -> AgentBackendSessionCleanupPayload | None: + runtime_layer_specs = _RUNTIME_LAYER_SPECS_ADAPTER.validate_json(row.composition_layer_specs or "[]") + if not runtime_layer_specs: + return None + + metadata: dict[str, JsonValue] = { + "tenant_id": row.tenant_id, + "app_id": row.app_id, + "agent_id": row.agent_id, + "agent_config_snapshot_id": row.agent_config_snapshot_id, + "previous_agent_backend_run_id": row.backend_run_id, + } + if row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION: + metadata["conversation_id"] = row.conversation_id + idempotency_key = ( + f"{row.tenant_id}:{row.app_id}:{row.conversation_id}:" + f"{row.agent_id}:app-delete-cleanup:{row.id or row.backend_run_id or 'no-session-id'}" + ) + else: + metadata["workflow_run_id"] = row.workflow_run_id + metadata["node_id"] = row.node_id + idempotency_key = ( + f"{row.tenant_id}:{row.app_id}:{row.workflow_run_id}:{row.node_id}:" + f"{row.agent_id}:app-delete-cleanup:{row.id or row.backend_run_id or 'no-session-id'}" + ) + + return AgentBackendSessionCleanupPayload( + session_snapshot=CompositorSessionSnapshot.model_validate_json(row.session_snapshot), + runtime_layer_specs=runtime_layer_specs, + idempotency_key=idempotency_key, + metadata=metadata, + ) + + +def _enqueue_agent_runtime_session_cleanup( + *, + row: AgentRuntimeSession, + payload: AgentBackendSessionCleanupPayload, +) -> None: + payload_dict = payload.model_dump(mode="json") + if row.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION: + cleanup_conversation_agent_runtime_session.delay(payload_dict) + return + cleanup_workflow_agent_runtime_session.delay(payload_dict) + + def _delete_app_model_configs(tenant_id: str, app_id: str): def del_model_config(session, model_config_id: str): session.execute( diff --git a/api/tests/test_containers_integration_tests/services/test_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_conversation_service.py index 19dd4d6cf70..60858df08e8 100644 --- a/api/tests/test_containers_integration_tests/services/test_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_conversation_service.py @@ -6,11 +6,13 @@ from unittest.mock import patch from uuid import uuid4 import pytest +from agenton.compositor import CompositorSessionSnapshot from sqlalchemy import select from sqlalchemy.orm import Session +from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore from core.app.entities.app_invoke_entities import InvokeFrom -from models import TenantAccountRole +from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus, TenantAccountRole from models.account import Account, Tenant, TenantAccountJoin from models.enums import ConversationFromSource, EndUserType from models.model import App, Conversation, EndUser, Message, MessageAnnotation @@ -1081,8 +1083,9 @@ class TestConversationServiceExport: # Assert assert result == conversation + @patch("services.conversation_service.cleanup_conversation_agent_runtime_session") @patch("services.conversation_service.delete_conversation_related_data") - def test_delete_conversation(self, mock_delete_task, db_session_with_containers: Session): + def test_delete_conversation(self, mock_delete_task, mock_cleanup_task, db_session_with_containers: Session): """ Test conversation deletion with async cleanup. @@ -1101,6 +1104,20 @@ class TestConversationServiceExport: user, ) conversation_id = conversation.id + runtime_session = AgentRuntimeSession( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id=str(uuid4()), + agent_config_snapshot_id=str(uuid4()), + backend_run_id="backend-run-1", + session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(), + composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]', + conversation_id=conversation.id, + status=AgentRuntimeSessionStatus.ACTIVE, + ) + db_session_with_containers.add(runtime_session) + db_session_with_containers.commit() # Act - Delete the conversation ConversationService.delete( @@ -1115,9 +1132,29 @@ class TestConversationServiceExport: # Step 2: Async cleanup task triggered # The Celery task will handle cleanup of messages, annotations, etc. mock_delete_task.delay.assert_called_once_with(conversation_id) + mock_cleanup_task.delay.assert_called_once() + cleanup_payload = mock_cleanup_task.delay.call_args.args[0] + assert cleanup_payload["metadata"]["conversation_id"] == conversation_id + assert ( + cleanup_payload["idempotency_key"] + == f"{app_model.tenant_id}:{app_model.id}:{conversation_id}:agent-runtime-session-cleanup:" + f"{runtime_session.agent_id}:{runtime_session.agent_config_snapshot_id}:{runtime_session.backend_run_id}" + ) + runtime_session_row = db_session_with_containers.scalar( + select(AgentRuntimeSession).where(AgentRuntimeSession.id == runtime_session.id) + ) + assert runtime_session_row is not None + assert runtime_session_row.status == AgentRuntimeSessionStatus.CLEANED + + @patch("services.conversation_service.cleanup_conversation_agent_runtime_session") @patch("services.conversation_service.delete_conversation_related_data") - def test_delete_conversation_not_owned_by_account(self, mock_delete_task, db_session_with_containers: Session): + def test_delete_conversation_not_owned_by_account( + self, + mock_delete_task, + mock_cleanup_task, + db_session_with_containers: Session, + ): """ Test deletion is denied when conversation belongs to a different account. """ @@ -1147,14 +1184,22 @@ class TestConversationServiceExport: not_deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id)) assert not_deleted is not None mock_delete_task.delay.assert_not_called() + mock_cleanup_task.delay.assert_not_called() + @patch("services.conversation_service.cleanup_conversation_agent_runtime_session") @patch("services.conversation_service.delete_conversation_related_data") - def test_delete_handles_exception_and_rollback(self, mock_delete_task, db_session_with_containers: Session): + def test_delete_handles_exception_and_rollback( + self, + mock_delete_task, + mock_cleanup_task, + db_session_with_containers: Session, + ): """ Test that delete propagates exceptions and does not trigger the cleanup task. - When a DB error occurs during deletion, the service must rollback the - transaction and re-raise the exception without scheduling async cleanup. + When a DB error occurs during deletion, the conversation row stays in + place, but any already-enqueued Agent backend cleanup remains a + best-effort terminal lifecycle action. """ # Arrange app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( @@ -1164,6 +1209,20 @@ class TestConversationServiceExport: db_session_with_containers, app_model, user ) conversation_id = conversation.id + runtime_session = AgentRuntimeSession( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id=str(uuid4()), + agent_config_snapshot_id=str(uuid4()), + backend_run_id="backend-run-rollback", + session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(), + composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]', + conversation_id=conversation.id, + status=AgentRuntimeSessionStatus.ACTIVE, + ) + db_session_with_containers.add(runtime_session) + db_session_with_containers.commit() # Act — force an error during the delete to exercise the rollback path with patch.object(db_session_with_containers, "delete", side_effect=Exception("DB error")): @@ -1175,9 +1234,111 @@ class TestConversationServiceExport: session=db_session_with_containers, ) - # Assert — async cleanup must NOT have been scheduled + # Assert — related-data deletion is not scheduled, but the backend + # cleanup task was already enqueued before the row delete failed. mock_delete_task.delay.assert_not_called() + mock_cleanup_task.delay.assert_called_once() + cleanup_payload = mock_cleanup_task.delay.call_args.args[0] + assert ( + cleanup_payload["idempotency_key"] + == f"{app_model.tenant_id}:{app_model.id}:{conversation_id}:agent-runtime-session-cleanup:" + f"{runtime_session.agent_id}:{runtime_session.agent_config_snapshot_id}:{runtime_session.backend_run_id}" + ) # Conversation is still present because the deletion was never committed still_there = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id)) assert still_there is not None + + @patch("services.conversation_service.cleanup_conversation_agent_runtime_session") + @patch("services.conversation_service.delete_conversation_related_data") + def test_delete_ignores_mark_cleaned_failure( + self, + mock_delete_task, + mock_cleanup_task, + db_session_with_containers: Session, + ): + app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( + db_session_with_containers + ) + conversation = ConversationServiceIntegrationTestDataFactory.create_conversation( + db_session_with_containers, + app_model, + user, + ) + runtime_session = AgentRuntimeSession( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id=str(uuid4()), + agent_config_snapshot_id=str(uuid4()), + backend_run_id="backend-run-cleanup-failure", + session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(), + composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]', + conversation_id=conversation.id, + status=AgentRuntimeSessionStatus.ACTIVE, + ) + db_session_with_containers.add(runtime_session) + db_session_with_containers.commit() + + with patch.object(AgentAppRuntimeSessionStore, "mark_cleaned", side_effect=RuntimeError("cleanup failed")): + ConversationService.delete( + app_model=app_model, + conversation_id=conversation.id, + user=user, + session=db_session_with_containers, + ) + + deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation.id)) + assert deleted is None + mock_delete_task.delay.assert_called_once_with(conversation.id) + mock_cleanup_task.delay.assert_called_once() + + @patch("services.conversation_service.cleanup_conversation_agent_runtime_session") + @patch("services.conversation_service.delete_conversation_related_data") + def test_delete_ignores_cleanup_enqueue_failure_and_still_retires_runtime_session( + self, + mock_delete_task, + mock_cleanup_task, + db_session_with_containers: Session, + ): + app_model, user = ConversationServiceIntegrationTestDataFactory.create_app_and_account( + db_session_with_containers + ) + conversation = ConversationServiceIntegrationTestDataFactory.create_conversation( + db_session_with_containers, + app_model, + user, + ) + conversation_id = conversation.id + runtime_session = AgentRuntimeSession( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id=str(uuid4()), + agent_config_snapshot_id=str(uuid4()), + backend_run_id="backend-run-enqueue-failure", + session_snapshot=CompositorSessionSnapshot(layers=[]).model_dump_json(), + composition_layer_specs='[{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]', + conversation_id=conversation.id, + status=AgentRuntimeSessionStatus.ACTIVE, + ) + db_session_with_containers.add(runtime_session) + db_session_with_containers.commit() + mock_cleanup_task.delay.side_effect = RuntimeError("queue down") + + ConversationService.delete( + app_model=app_model, + conversation_id=conversation_id, + user=user, + session=db_session_with_containers, + ) + + deleted = db_session_with_containers.scalar(select(Conversation).where(Conversation.id == conversation_id)) + assert deleted is None + mock_delete_task.delay.assert_called_once_with(conversation_id) + mock_cleanup_task.delay.assert_called_once() + runtime_session_row = db_session_with_containers.scalar( + select(AgentRuntimeSession).where(AgentRuntimeSession.id == runtime_session.id) + ) + assert runtime_session_row is not None + assert runtime_session_row.status == AgentRuntimeSessionStatus.CLEANED diff --git a/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py index f6c73fdb66d..4adef713cfd 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py +++ b/api/tests/unit_tests/clients/agent_backend/test_event_adapter.py @@ -15,6 +15,7 @@ from dify_agent.protocol import ( from pydantic_ai.messages import FinalResultEvent from clients.agent_backend import ( + AgentBackendAgentMessageDeltaInternalEvent, AgentBackendDeferredToolCallInternalEvent, AgentBackendInternalEventType, AgentBackendRunCancelledInternalEvent, @@ -54,6 +55,25 @@ def test_event_adapter_maps_pydantic_ai_stream_event(): assert event.data["event_kind"] == "final_result" +def test_event_adapter_maps_pydantic_ai_stream_event_agent_message_delta_annotation(): + adapted = AgentBackendRunEventAdapter().adapt( + PydanticAIStreamRunEvent( + id="2-0", + run_id="run-1", + data=FinalResultEvent(tool_name=None, tool_call_id=None), + agent_message_delta="hello", + ) + ) + + assert adapted == [ + AgentBackendAgentMessageDeltaInternalEvent( + run_id="run-1", + source_event_id="2-0", + delta="hello", + ) + ] + + def test_event_adapter_maps_run_succeeded_to_final_output(): snapshot = CompositorSessionSnapshot(layers=[]) adapted = AgentBackendRunEventAdapter().adapt( diff --git a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py index f3aac73aac7..b2b77275fa7 100644 --- a/api/tests/unit_tests/clients/agent_backend/test_request_builder.py +++ b/api/tests/unit_tests/clients/agent_backend/test_request_builder.py @@ -88,6 +88,7 @@ def _run_input() -> AgentBackendWorkflowNodeRunInput: def test_request_builder_outputs_dify_agent_create_run_request(): request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) + dumped = request.model_dump(mode="json") assert isinstance(request, CreateRunRequest) assert [layer.name for layer in request.composition.layers] == [ @@ -102,6 +103,7 @@ def test_request_builder_outputs_dify_agent_create_run_request(): assert request.on_exit.default is ExitIntent.SUSPEND assert request.idempotency_key == "workflow-run-1:node-execution-1" assert request.metadata == {"workflow_id": "workflow-1", "node_id": "node-1"} + assert "purpose" not in dumped def test_request_builder_separates_agent_soul_and_workflow_job_prompt(): @@ -121,6 +123,59 @@ def test_request_builder_separates_agent_soul_and_workflow_job_prompt(): assert dumped["composition"]["layers"][2]["config"]["user"] == "Summarize the report." +@pytest.mark.parametrize("agent_config_version_kind", ["snapshot", "draft"]) +def test_agent_app_request_builder_keeps_agent_soul_prompt_for_snapshot_and_draft( + agent_config_version_kind: str, +): + original_prompt = " You are Iris. \n" + run_input = _agent_app_input().model_copy( + update={ + "agent_config_version_kind": agent_config_version_kind, + "agent_soul_prompt": original_prompt, + } + ) + + request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config) + assert prompt_config.prefix == original_prompt + + +def test_agent_app_request_builder_wraps_agent_soul_prompt_for_build_draft(): + original_prompt = " You are Iris. \n" + run_input = _agent_app_input().model_copy( + update={ + "agent_config_version_kind": "build_draft", + "agent_soul_prompt": original_prompt, + } + ) + + request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config) + assert prompt_config.prefix != original_prompt + assert prompt_config.prefix.startswith("You are running in build mode.") + assert "```text\nYou are Iris.\n```" in prompt_config.prefix + + +def test_agent_app_request_builder_uses_longer_fence_for_build_draft_prompt_body(): + run_input = _agent_app_input().model_copy( + update={ + "agent_config_version_kind": "build_draft", + "agent_soul_prompt": "Keep this snippet:\n```python\nprint('hi')\n```", + } + ) + + request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config) + assert "````text" in prompt_config.prefix + assert "```python" in prompt_config.prefix + + def test_request_builder_sets_model_and_output_layer_contract_ids(): request = AgentBackendRunRequestBuilder().build_for_workflow_node(_run_input()) layers = {layer.name: layer for layer in request.composition.layers} @@ -250,6 +305,7 @@ def test_request_builder_builds_cleanup_request_replays_persisted_layer_specs(): assert request.on_exit.default is ExitIntent.DELETE assert request.idempotency_key == "run-1:node-1:binding-1:agent-session-cleanup" assert request.metadata["agent_backend_lifecycle"] == "session_cleanup" + assert "purpose" not in request.model_dump(mode="json") def test_request_builder_rejects_empty_runtime_layer_specs(): @@ -392,6 +448,19 @@ def test_agent_app_request_builder_omits_shell_layer_by_default(): assert DIFY_SHELL_LAYER_ID not in {layer.name for layer in request.composition.layers} +def test_agent_app_request_builder_keeps_build_draft_prompt_when_agent_soul_prompt_is_blank(): + run_input = _agent_app_input().model_copy( + update={"agent_soul_prompt": " ", "agent_config_version_kind": "build_draft"} + ) + + request = AgentBackendRunRequestBuilder().build_for_agent_app(run_input) + layers = {layer.name: layer for layer in request.composition.layers} + + prompt_config = cast(PromptLayerConfig, layers[AGENT_SOUL_PROMPT_LAYER_ID].config) + assert "You are running in build mode." in prompt_config.prefix + assert "No task prompt was provided." in prompt_config.prefix + + def test_agent_app_request_builder_adds_shell_layer_when_include_shell(): run_input = _agent_app_input(include_shell=True) run_input.shell_config = DifyShellLayerConfig(env=[DifyShellEnvVarConfig(name="APP_ENV", value="enabled")]) diff --git a/api/tests/unit_tests/clients/agent_backend/test_session_cleanup.py b/api/tests/unit_tests/clients/agent_backend/test_session_cleanup.py new file mode 100644 index 00000000000..6b72850aeca --- /dev/null +++ b/api/tests/unit_tests/clients/agent_backend/test_session_cleanup.py @@ -0,0 +1,124 @@ +from datetime import UTC, datetime + +from agenton.compositor import CompositorSessionSnapshot +from agenton.compositor.schemas import LayerSessionSnapshot +from agenton.layers.base import LifecycleState +from dify_agent.protocol import RunStatusResponse + +from clients.agent_backend import ( + AgentBackendError, + AgentBackendSessionCleanupPayload, + FakeAgentBackendRunClient, + RuntimeLayerSpec, + cleanup_agent_backend_session, +) + + +def _payload() -> AgentBackendSessionCleanupPayload: + return AgentBackendSessionCleanupPayload( + session_snapshot=CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot( + name="history", + lifecycle_state=LifecycleState.SUSPENDED, + runtime_state={}, + ) + ] + ), + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + idempotency_key="cleanup-1", + metadata={"tenant_id": "tenant-1"}, + timeout_seconds=15.0, + ) + + +def test_cleanup_agent_backend_session_runs_create_and_wait_until_success(): + client = FakeAgentBackendRunClient(run_id="cleanup-run-1") + + result = cleanup_agent_backend_session(payload=_payload(), client=client) + + assert result.status == "succeeded" + assert result.cleanup_run_id == "cleanup-run-1" + assert client.request is not None + assert [layer.name for layer in client.request.composition.layers] == ["history"] + + +def test_cleanup_agent_backend_session_skips_when_client_is_missing(): + result = cleanup_agent_backend_session( + payload=_payload(), + client=None, + ) + + assert result.status == "skipped" + assert result.reason == "no_agent_backend_client" + + +def test_cleanup_agent_backend_session_skips_when_session_snapshot_is_missing(): + payload = _payload().model_copy(update={"session_snapshot": None}) + + result = cleanup_agent_backend_session(payload=payload, client=FakeAgentBackendRunClient()) + + assert result.status == "skipped" + assert result.reason == "missing_session_snapshot" + + +def test_cleanup_agent_backend_session_skips_when_runtime_layer_specs_are_missing(): + payload = _payload().model_copy(update={"runtime_layer_specs": []}) + + result = cleanup_agent_backend_session(payload=payload, client=FakeAgentBackendRunClient()) + + assert result.status == "skipped" + assert result.reason == "missing_runtime_layer_specs" + + +class _FailedStatusClient(FakeAgentBackendRunClient): + def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + del timeout_seconds + return RunStatusResponse( + run_id=run_id, + status="failed", + created_at=datetime(2026, 1, 1, tzinfo=UTC), + updated_at=datetime(2026, 1, 1, tzinfo=UTC), + error="snapshot mismatch", + ) + + +def test_cleanup_agent_backend_session_reports_failed_terminal_status(): + client = _FailedStatusClient(run_id="cleanup-run-2") + + result = cleanup_agent_backend_session(payload=_payload(), client=client) + + assert result.status == "failed" + assert result.reason == "snapshot mismatch" + assert result.cleanup_run_id == "cleanup-run-2" + + +class _CreateRunFailureClient(FakeAgentBackendRunClient): + def create_run(self, request): # type: ignore[override] + del request + raise AgentBackendError("create run failed") + + +class _WaitRunFailureClient(FakeAgentBackendRunClient): + def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: + del run_id, timeout_seconds + raise AgentBackendError("wait run failed") + + +def test_cleanup_agent_backend_session_returns_failed_when_create_run_raises(): + result = cleanup_agent_backend_session(payload=_payload(), client=_CreateRunFailureClient()) + + assert result.status == "failed" + assert result.reason == "create run failed" + assert result.cleanup_run_id is None + + +def test_cleanup_agent_backend_session_returns_failed_with_cleanup_run_id_when_wait_run_raises(): + result = cleanup_agent_backend_session( + payload=_payload(), + client=_WaitRunFailureClient(run_id="cleanup-run-3"), + ) + + assert result.status == "failed" + assert result.reason == "wait run failed" + assert result.cleanup_run_id == "cleanup-run-3" diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 8f294293c07..51737a859ba 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -1576,6 +1576,7 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt( assert args["conversation_id"] == "debug-conversation-1" assert args["inputs"] == {} assert args["auto_generate_name"] is False + assert args[completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG] == "delete" assert args["external_trace_id"] == "trace-1" @@ -1664,6 +1665,51 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( assert args["external_trace_id"] == "trace-1" +def test_agent_chat_helper_ignores_private_exit_intent_payload_key( + app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +) -> None: + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") + current_user = SimpleNamespace(id=account_id) + captured: dict[str, object] = {} + + def generate(**kwargs: object) -> dict[str, object]: + captured.update(kwargs) + return {"answer": "ok"} + + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) + monkeypatch.setattr( + completion_controller, + "_resolve_current_user_agent_debug_conversation_id", + lambda **kwargs: "debug-conversation-1", + ) + monkeypatch.setattr( + completion_controller.helper, + "compact_generate_response", + lambda response: {"response": response}, + ) + + with app.test_request_context( + json={ + "inputs": {}, + "query": "hello", + "response_mode": "streaming", + completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG: "delete", + } + ): + result = completion_controller._create_chat_message( + current_user=current_user, + app_model=app_model, + session=Mock(), + ) + + assert result == {"response": {"answer": "ok"}} + assert captured["streaming"] is True + args = cast(dict[str, object], captured["args"]) + assert args["response_mode"] == "streaming" + assert args["conversation_id"] == "debug-conversation-1" + assert completion_controller.AGENT_RUNTIME_EXIT_INTENT_ARG not in args + + def test_agent_chat_helper_rejects_foreign_debug_conversation( app: Flask, monkeypatch: pytest.MonkeyPatch, diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index 6f8c6af2258..ef030e7e170 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -11,7 +11,6 @@ from __future__ import annotations import contextlib import json -from types import SimpleNamespace import pytest from pytest_mock import MockerFixture @@ -21,7 +20,8 @@ from core.app.apps.agent_app.app_generator import ( AgentAppGeneratorError, ) from core.app.apps.exc import GenerateTaskStoppedError -from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom +from core.app.entities.app_invoke_entities import AGENT_RUNTIME_EXIT_INTENT_ARG, InvokeFrom, UserFrom +from core.workflow.file_reference import build_file_reference from models import Account from models.agent import AgentConfigDraftType @@ -135,6 +135,79 @@ class TestGenerateSuccess: user=user, ) assert generate_entity.call_args.kwargs["prompt_file_mappings"] == file_mappings + assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "suspend" + + def test_generate_uses_delete_exit_intent_from_internal_arg(self, generator, mocker: MockerFixture): + app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") + user = DummyAccount("user") + + generator._resolve_agent = mocker.MagicMock( + return_value=(mocker.MagicMock(id="agent1"), "snap1", "snapshot", mocker.MagicMock()) + ) + generator._prepare_user_inputs = mocker.MagicMock(return_value={}) + generator._init_generate_records = mocker.MagicMock( + return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg")) + ) + generator._handle_response = mocker.MagicMock(return_value="raw-response") + + mocker.patch( + f"{MODULE}.AgentAppConfigManager.get_app_config", + return_value=mocker.MagicMock(variables=[], tenant_id="tenant", app_id="app1"), + ) + mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini")) + mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) + generate_entity = mocker.patch( + f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") + ) + mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"}) + + generator.generate( + app_model=app_model, + user=user, + args={"query": "hello", "inputs": {}, AGENT_RUNTIME_EXIT_INTENT_ARG: "delete"}, + invoke_from=InvokeFrom.DEBUGGER, + streaming=True, + ) + + assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "delete" + + def test_generate_falls_back_to_suspend_for_invalid_internal_exit_intent(self, generator, mocker: MockerFixture): + app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") + user = DummyAccount("user") + + generator._resolve_agent = mocker.MagicMock( + return_value=(mocker.MagicMock(id="agent1"), "snap1", "snapshot", mocker.MagicMock()) + ) + generator._prepare_user_inputs = mocker.MagicMock(return_value={}) + generator._init_generate_records = mocker.MagicMock( + return_value=(mocker.MagicMock(id="conv", mode="agent"), mocker.MagicMock(id="msg")) + ) + generator._handle_response = mocker.MagicMock(return_value="raw-response") + + mocker.patch( + f"{MODULE}.AgentAppConfigManager.get_app_config", + return_value=mocker.MagicMock(variables=[], tenant_id="tenant", app_id="app1"), + ) + mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock(model="gpt-4o-mini")) + mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) + generate_entity = mocker.patch( + f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") + ) + mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock()) + mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert", return_value={"result": "ok"}) + + generator.generate( + app_model=app_model, + user=user, + args={"query": "hello", "inputs": {}, AGENT_RUNTIME_EXIT_INTENT_ARG: "bogus"}, + invoke_from=InvokeFrom.DEBUGGER, + streaming=True, + ) + + assert generate_entity.call_args.kwargs["agent_runtime_exit_intent"] == "suspend" def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture): app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") @@ -205,81 +278,6 @@ class TestGenerateSuccess: assert generate_entity.call_args.kwargs["extras"] == {"auto_generate_conversation_name": True} - def test_generate_stateless_skips_chat_records(self, generator: AgentAppGenerator, mocker: MockerFixture): - app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent") - user = DummyAccount("user") - - generator._resolve_agent = mocker.MagicMock( - return_value=(mocker.MagicMock(id="agent1"), "build-draft-1", "build_draft", mocker.MagicMock()) - ) - generator._init_generate_records = mocker.MagicMock() - run_stateless = mocker.patch.object(generator, "_run_stateless", return_value={"result": "success"}) - converter = mocker.patch(f"{MODULE}.AgentAppGenerateResponseConverter.convert") - - result = generator.generate_stateless( - app_model=app_model, - user=user, - args={ - "query": "finalize", - "inputs": {}, - "conversation_id": "debug-conversation-1", - "draft_type": "debug_build", - }, - invoke_from=InvokeFrom.DEBUGGER, - ) - - assert result == {"result": "success"} - generator._init_generate_records.assert_not_called() - converter.assert_not_called() - run_call = run_stateless.call_args.kwargs - assert run_call["conversation_id"] == "debug-conversation-1" - assert run_call["runtime_session_snapshot_id"] == "build-draft-1" - - def test_generate_stateless_requires_conversation_id(self, generator: AgentAppGenerator, mocker: MockerFixture): - with pytest.raises(AgentAppGeneratorError, match="conversation_id is required"): - generator.generate_stateless( - app_model=mocker.MagicMock(), - user=DummyAccount("user"), - args={"query": "finalize", "inputs": {}, "draft_type": "debug_build"}, - invoke_from=InvokeFrom.DEBUGGER, - ) - - def test_stateless_run_uses_agent_app_runner(self, generator: AgentAppGenerator, mocker: MockerFixture): - app_model = mocker.MagicMock(id="app1", tenant_id="tenant", app_model_config=mocker.MagicMock()) - user = DummyAccount("user") - agent = mocker.MagicMock(id="agent1") - dify_context = SimpleNamespace(tenant_id="tenant", app_id="app1") - mocker.patch(f"{MODULE}.DifyRunContext", return_value=dify_context) - runner = mocker.MagicMock() - build_runner = mocker.patch.object(generator, "_build_runner", return_value=runner) - - result = generator._run_stateless( - app_model=app_model, - user=user, - invoke_from=InvokeFrom.DEBUGGER, - query="finalize", - conversation_id="debug-conversation-1", - agent=agent, - agent_config_id="build-draft-1", - agent_config_version_kind="build_draft", - agent_soul=mocker.MagicMock(), - runtime_session_snapshot_id="build-draft-1", - ) - - assert result == {"result": "success"} - build_runner.assert_called_once_with(dify_context) - runner.run_stateless.assert_called_once_with( - dify_context=dify_context, - agent_id="agent1", - agent_config_snapshot_id="build-draft-1", - agent_config_version_kind="build_draft", - agent_soul=mocker.ANY, - conversation_id="debug-conversation-1", - query="finalize", - idempotency_key=mocker.ANY, - session_scope_snapshot_id="build-draft-1", - ) - class TestGenerateWorker: @pytest.fixture(autouse=True) @@ -329,6 +327,7 @@ class TestGenerateWorker: query="query", runtime_session_snapshot_id="s", prompt_file_mappings=(), + agent_runtime_exit_intent="suspend", ): generator._generate_worker( flask_app=mocker.MagicMock(), @@ -337,6 +336,7 @@ class TestGenerateWorker: agent_id="a", agent_config_snapshot_id="s", agent_runtime_session_snapshot_id=runtime_session_snapshot_id, + agent_runtime_exit_intent=agent_runtime_exit_intent, model_conf=mocker.MagicMock(model="m"), query=query, prompt_file_mappings=prompt_file_mappings, @@ -364,6 +364,14 @@ class TestGenerateWorker: assert runner.run.call_args.kwargs["agent_config_snapshot_id"] == "s" assert runner.run.call_args.kwargs["session_scope_snapshot_id"] is None + def test_worker_forwards_runtime_exit_intent_to_runner(self, generator, mocker: MockerFixture): + runner = self._wire(generator, mocker) + queue_manager = mocker.MagicMock() + + self._call(generator, mocker, queue_manager, agent_runtime_exit_intent="delete") + + assert runner.run.call_args.kwargs["agent_runtime_exit_intent"] == "delete" + def test_worker_appends_prompt_files_to_backend_query(self, generator, mocker: MockerFixture): runner = self._wire(generator, mocker, guard_query="你看得见这张图片吗") queue_manager = mocker.MagicMock() @@ -373,7 +381,23 @@ class TestGenerateWorker: "transfer_method": "local_file", "url": "", "upload_file_id": "upload-file-1", - } + }, + { + "type": "document", + "transfer_method": "remote_url", + "url": "https://example.com/source.pdf", + "upload_file_id": "ignored", + }, + ] + expected_file_mappings = [ + { + "transfer_method": "local_file", + "reference": build_file_reference(record_id="upload-file-1"), + }, + { + "transfer_method": "remote_url", + "url": "https://example.com/source.pdf", + }, ] self._call( @@ -385,7 +409,10 @@ class TestGenerateWorker: ) assert runner.run.call_args.kwargs["query"] == ( - f"你看得见这张图片吗\n{json.dumps(file_mappings, ensure_ascii=False)}" + "你看得见这张图片吗\nUser provided files: " + "use dify-agent file download with the listed transfer_method and reference/url " + "to get the files and investigate them\n" + f"{json.dumps(expected_file_mappings, ensure_ascii=False, separators=(',', ':'))}" ) def test_input_guard_short_circuit_skips_backend(self, generator, mocker: MockerFixture): @@ -461,6 +488,7 @@ class TestResumeAfterFormSubmission: # The paused turn's query is re-sent verbatim — never blank. assert entity.call_args.kwargs["query"] == "original question" + assert "agent_runtime_exit_intent" not in entity.call_args.kwargs def test_resume_falls_back_to_placeholder_when_no_paused_message(self, generator, mocker: MockerFixture): entity = self._wire(generator, mocker) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index e0a7687de95..ffef1cf3be8 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -28,8 +28,6 @@ from pydantic_ai.messages import ( FunctionToolCallEvent, FunctionToolResultEvent, PartDeltaEvent, - PartStartEvent, - TextPart, TextPartDelta, ThinkingPartDelta, ToolCallPart, @@ -49,7 +47,12 @@ from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeReque from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.app.entities.queue_entities import QueueAgentThoughtEvent, QueueLLMChunkEvent, QueueMessageEndEvent +from core.app.entities.queue_entities import ( + QueueAgentMessageEvent, + QueueAgentThoughtEvent, + QueueLLMChunkEvent, + QueueMessageEndEvent, +) from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome from models.agent_config_entities import AgentSoulConfig from models.model import MessageAgentThought @@ -98,24 +101,6 @@ class _RecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): return super().cancel_run(run_id, request=request) -class _BlockingRecordingFakeAgentBackendRunClient(FakeAgentBackendRunClient): - def __init__(self, **kwargs: Any) -> None: - super().__init__(**kwargs) - self.wait_calls: list[tuple[str, float | None]] = [] - self.stream_called = False - - @override - def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: - del run_id, after - self.stream_called = True - return iter(()) - - @override - def wait_run(self, run_id: str, *, timeout_seconds: float | None = None): - self.wait_calls.append((run_id, timeout_seconds)) - return super().wait_run(run_id, timeout_seconds=timeout_seconds) - - class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): @override def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: @@ -127,12 +112,14 @@ class _StreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + agent_message_delta="hello ", ) yield PydanticAIStreamRunEvent( id="3-0", run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")), + agent_message_delta="agent", ) yield RunSucceededEvent( id="4-0", @@ -157,12 +144,14 @@ class _StreamingRecordingFakeAgentBackendRunClient(_RecordingFakeAgentBackendRun run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + agent_message_delta="hello ", ) yield PydanticAIStreamRunEvent( id="3-0", run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")), + agent_message_delta="agent", ) yield RunSucceededEvent( id="4-0", @@ -190,6 +179,7 @@ class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgent run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + agent_message_delta="hello ", ) self._queue_manager.request_stop() yield PydanticAIStreamRunEvent( @@ -197,10 +187,11 @@ class _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(_RecordingFakeAgent run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")), + agent_message_delta="agent", ) -class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient): +class _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient(FakeAgentBackendRunClient): @override def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: del after @@ -210,7 +201,8 @@ class _StreamingPartStartFakeAgentBackendRunClient(FakeAgentBackendRunClient): id="2-0", run_id=run_id, created_at=created_at, - data=PartStartEvent(index=0, part=TextPart(content="hello")), + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello")), + agent_message_delta="hello", ) yield RunSucceededEvent( id="3-0", @@ -251,6 +243,7 @@ class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClien run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="streamed answer")), + agent_message_delta="streamed answer", ) yield RunSucceededEvent( id="3-0", @@ -263,6 +256,37 @@ class _StreamingTextNullOutputFakeAgentBackendRunClient(FakeAgentBackendRunClien ) +class _AgentAnswerStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): + @override + def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: + del after + created_at = datetime(2026, 1, 1, tzinfo=UTC) + yield RunStartedEvent(id="1-0", run_id=run_id, created_at=created_at) + yield PydanticAIStreamRunEvent( + id="2-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello ")), + agent_message_delta="hello ", + ) + yield PydanticAIStreamRunEvent( + id="3-0", + run_id=run_id, + created_at=created_at, + data=PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="agent")), + agent_message_delta="agent", + ) + yield RunSucceededEvent( + id="4-0", + run_id=run_id, + created_at=created_at, + data=RunSucceededEventData( + output={"text": "final answer"}, + session_snapshot=CompositorSessionSnapshot(layers=[]), + ), + ) + + class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): @override def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]: @@ -292,6 +316,7 @@ class _ProcessStreamingFakeAgentBackendRunClient(FakeAgentBackendRunClient): run_id=run_id, created_at=created_at, data=PartDeltaEvent(index=1, delta=TextPartDelta(content_delta="final answer")), + agent_message_delta="final answer", ) yield RunSucceededEvent( id="6-0", @@ -318,6 +343,9 @@ class _FakeDbSession: def get(self, _model: type[MessageAgentThought], row_id: str) -> MessageAgentThought | None: return self.rows.get(row_id) + def delete(self, row: MessageAgentThought) -> None: + self.rows.pop(str(row.id), None) + def rollback(self) -> None: self.rollback_count += 1 @@ -327,9 +355,11 @@ class _FakeSessionStore: self, loaded: CompositorSessionSnapshot | None = None, loaded_session: StoredAgentAppSession | None = None, + listed_sessions: list[StoredAgentAppSession] | None = None, ) -> None: self.loaded = loaded self._loaded_session = loaded_session + self._listed_sessions = list(listed_sessions or []) self.loaded_scopes: list[AgentAppSessionScope] = [] self.saved: list[ tuple[ @@ -341,6 +371,7 @@ class _FakeSessionStore: str | None, ] ] = [] + self.cleaned: list[tuple[AgentAppSessionScope, str | None]] = [] def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None: self.loaded_scopes.append(scope) @@ -354,6 +385,14 @@ class _FakeSessionStore: return None return StoredAgentAppSession(scope=scope, session_snapshot=self.loaded, backend_run_id=None) + def list_active_sessions_for_conversation( + self, *, tenant_id: str, app_id: str, conversation_id: str + ) -> list[StoredAgentAppSession]: + assert tenant_id == "tenant-1" + assert app_id == "app-1" + assert conversation_id == "conv-1" + return list(self._listed_sessions) + def save_active_snapshot( self, *, @@ -368,6 +407,9 @@ class _FakeSessionStore: (scope, backend_run_id, snapshot, list(runtime_layer_specs), pending_form_id, pending_tool_call_id) ) + def mark_cleaned(self, *, scope: AgentAppSessionScope, backend_run_id: str | None = None) -> None: + self.cleaned.append((scope, backend_run_id)) + class _MonotonicClock: def __init__(self, *values: float) -> None: @@ -409,7 +451,7 @@ def _runner( client: FakeAgentBackendRunClient, store: _FakeSessionStore, *, - text_delta_debounce_seconds: float | None = 0, + text_delta_debounce_seconds: float = 0, ) -> AgentAppRunner: return AgentAppRunner( request_builder=AgentAppRuntimeRequestBuilder( @@ -423,7 +465,7 @@ def _runner( ) -def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None: +def _run(runner: AgentAppRunner, qm: _FakeQueueManager, *, agent_runtime_exit_intent: str = "suspend") -> None: runner.run( dify_context=_dify_ctx(), agent_id="agent-1", @@ -434,18 +476,7 @@ def _run(runner: AgentAppRunner, qm: _FakeQueueManager) -> None: message_id="msg-1", model_name="gpt-4o-mini", queue_manager=qm, # type: ignore[arg-type] - ) - - -def _run_stateless(runner: AgentAppRunner) -> None: - runner.run_stateless( - dify_context=_dify_ctx(), - agent_id="agent-1", - agent_config_snapshot_id="snap-1", - agent_soul=_soul(), - conversation_id="conv-1", - query="finalize", - idempotency_key="run-req-1", + agent_runtime_exit_intent=agent_runtime_exit_intent, # type: ignore[arg-type] ) @@ -470,6 +501,8 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): _run(_runner(client, store), qm) + assert client.request is not None + assert client.request.on_exit.default.value == "suspend" # One LLM chunk + one message-end, carrying the backend's answer text. chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] @@ -490,38 +523,169 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(): # A successful turn carries no ask_human pause correlation. assert pending_form_id is None assert pending_tool_call_id is None + assert store.cleaned == [] -def test_successful_turn_forwards_agent_backend_stream_text_deltas_without_duplicate_terminal_chunk(): +def test_successful_turn_enqueues_cleanup_for_superseded_sessions_after_saving_snapshot(monkeypatch): + superseded = StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-2", + agent_config_snapshot_id="snap-2", + ), + session_snapshot=CompositorSessionSnapshot(layers=[]), + backend_run_id="run-old", + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + ) + current_scope_session = StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + ), + session_snapshot=CompositorSessionSnapshot(layers=[]), + backend_run_id="run-current", + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + ) + store = _FakeSessionStore(listed_sessions=[current_scope_session, superseded]) + client = FakeAgentBackendRunClient() + qm = _FakeQueueManager() + cleanup_delay = MagicMock() + monkeypatch.setattr(app_runner_module.cleanup_conversation_agent_runtime_session, "delay", cleanup_delay) + + _run(_runner(client, store), qm) + + assert store.saved + cleanup_delay.assert_called_once() + payload = cleanup_delay.call_args.args[0] + assert payload["metadata"]["conversation_id"] == "conv-1" + assert payload["metadata"]["agent_id"] == "agent-2" + assert payload["metadata"]["previous_agent_backend_run_id"] == "run-old" + assert payload["idempotency_key"] == "tenant-1:app-1:conv-1:agent-2:snap-2:superseded-session-cleanup:run-old" + + +def test_superseded_session_cleanup_enqueue_failure_does_not_fail_turn(monkeypatch): + superseded = StoredAgentAppSession( + scope=AgentAppSessionScope( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + agent_id="agent-2", + agent_config_snapshot_id="snap-2", + ), + session_snapshot=CompositorSessionSnapshot(layers=[]), + backend_run_id="run-old", + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + ) + store = _FakeSessionStore(listed_sessions=[superseded]) + client = FakeAgentBackendRunClient() + qm = _FakeQueueManager() + cleanup_delay = MagicMock(side_effect=RuntimeError("queue down")) + monkeypatch.setattr(app_runner_module.cleanup_conversation_agent_runtime_session, "delay", cleanup_delay) + + _run(_runner(client, store), qm) + + cleanup_delay.assert_called_once() + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "hello agent" + + +def test_delete_on_exit_turn_marks_session_cleaned_without_saving_snapshot(): + client = _StreamingRecordingFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store), qm, agent_runtime_exit_intent="delete") + + assert client.request is not None + assert client.request.on_exit.default.value == "delete" + assert store.saved == [] + assert len(store.cleaned) == 1 + cleaned_scope, cleaned_run_id = store.cleaned[0] + assert cleaned_scope.conversation_id == "conv-1" + assert cleaned_scope.agent_config_snapshot_id == "snap-1" + assert cleaned_run_id == "fake-run-1" + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "hello agent" + + +def test_delete_on_exit_turn_swallows_cleanup_failure_after_success(): + client = _StreamingRecordingFakeAgentBackendRunClient() + store = _FakeSessionStore() + store.mark_cleaned = MagicMock(side_effect=RuntimeError("cleanup failed")) # type: ignore[method-assign] + qm = _FakeQueueManager() + + _run(_runner(client, store), qm, agent_runtime_exit_intent="delete") + + assert store.saved == [] + store.mark_cleaned.assert_called_once() + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert len(end_events) == 1 + + +def test_delete_on_exit_turn_marks_session_cleaned_when_publish_fails(): + client = _StreamingRecordingFakeAgentBackendRunClient() + store = _FakeSessionStore() + store.mark_cleaned = MagicMock(side_effect=RuntimeError("cleanup failed")) # type: ignore[method-assign] + qm = _FakeQueueManager() + runner = _runner(client, store) + runner._publish_terminal_answer = MagicMock(side_effect=RuntimeError("publish failed")) + + with pytest.raises(RuntimeError, match="publish failed"): + _run(runner, qm, agent_runtime_exit_intent="delete") + + assert store.saved == [] + store.mark_cleaned.assert_called_once() + + +def test_successful_turn_routes_stream_text_to_agent_message_and_uses_terminal_output(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) client = _StreamingFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store, text_delta_debounce_seconds=0), qm) + _run(_runner(client, store), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] - assert [event.chunk.delta.message.content for event in chunk_events] == ["hello ", "agent"] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"] + assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello ", "agent"] assert len(end_events) == 1 assert end_events[0].llm_result.message.content == "hello agent" assert end_events[0].llm_result.usage.prompt_tokens == 3 assert end_events[0].llm_result.usage.completion_tokens == 5 assert end_events[0].llm_result.usage.total_tokens == 8 + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert rows == [] assert store.saved -def test_successful_turn_forwards_part_start_text_and_publishes_missing_terminal_suffix(): - client = _StreamingPartStartFakeAgentBackendRunClient() +def test_successful_turn_routes_single_agent_message_delta(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + client = _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store, text_delta_debounce_seconds=0), qm) + _run(_runner(client, store), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] - assert [event.chunk.delta.message.content for event in chunk_events] == ["hello", " agent"] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"] + assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello"] assert len(end_events) == 1 assert end_events[0].llm_result.message.content == "hello agent" + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert rows == [] def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_literal_null(): @@ -532,24 +696,77 @@ def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_li _run(_runner(client, store), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] assert chunk_events == [] + assert agent_message_events == [] assert len(end_events) == 1 assert end_events[0].llm_result.message.content == "" -def test_successful_turn_with_streamed_text_and_null_terminal_output_keeps_streamed_answer(): +def test_successful_turn_with_stream_text_and_null_terminal_output_keeps_empty_message(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) client = _StreamingTextNullOutputFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store, text_delta_debounce_seconds=0), qm) + _run(_runner(client, store), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] - assert [event.chunk.delta.message.content for event in chunk_events] == ["streamed answer"] + assert chunk_events == [] + assert [event.chunk.delta.message.content for event in agent_message_events] == ["streamed answer"] assert len(end_events) == 1 - assert end_events[0].llm_result.message.content == "streamed answer" + assert end_events[0].llm_result.message.content == "" + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert len(rows) == 1 + assert rows[0].answer == "streamed answer" + + +def test_successful_turn_routes_agent_answer_to_agent_message(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + client = _AgentAnswerStreamingFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"] + assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello ", "agent"] + end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] + assert len(end_events) == 1 + assert end_events[0].llm_result.message.content == "final answer" + thought_events = [e for e in qm.events if isinstance(e, QueueAgentThoughtEvent)] + assert len(thought_events) == 2 + + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert len(rows) == 1 + assert rows[0].answer == "hello agent" + assert rows[0].thought == "" + assert rows[0].tool == "" + + +def test_agent_message_deltas_are_debounced_to_agent_message(monkeypatch): + monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0, 0.2)) + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + client = _StreamingFakeAgentBackendRunClient() + store = _FakeSessionStore() + qm = _FakeQueueManager() + + _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) + + chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] + assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"] + assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello agent"] + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert rows == [] def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): @@ -559,10 +776,12 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store, text_delta_debounce_seconds=0), qm) + _run(_runner(client, store), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] assert [event.chunk.delta.message.content for event in chunk_events] == ["final answer"] + assert [event.chunk.delta.message.content for event in agent_message_events] == ["final answer"] thought_events = [e for e in qm.events if isinstance(e, QueueAgentThoughtEvent)] assert len(thought_events) >= 3 @@ -572,49 +791,26 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch): assert rows[1].tool == "bash" assert rows[1].tool_input == '{"cmd": "ls"}' assert rows[1].observation == "ok" + assert len(rows) == 2 -def test_streaming_turn_batches_text_deltas_within_debounce_window(monkeypatch): - monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0, 0.2)) - client = _StreamingFakeAgentBackendRunClient() - store = _FakeSessionStore() - qm = _FakeQueueManager() - - _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) - - chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] - end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] - assert [event.chunk.delta.message.content for event in chunk_events] == ["hello agent"] - assert len(end_events) == 1 - assert end_events[0].llm_result.message.content == "hello agent" - - -def test_streaming_turn_flushes_pending_text_before_terminal_success(monkeypatch): - monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0)) - client = _StreamingPartStartFakeAgentBackendRunClient() - store = _FakeSessionStore() - qm = _FakeQueueManager() - - _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) - - chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] - end_events = [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] - assert [event.chunk.delta.message.content for event in chunk_events] == ["hello", " agent"] - assert len(end_events) == 1 - assert end_events[0].llm_result.message.content == "hello agent" - - -def test_streaming_turn_flushes_pending_text_before_stop_and_cancel(monkeypatch): - monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0)) +def test_streaming_turn_cancels_after_persisting_seen_agent_answer(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) store = _FakeSessionStore() qm = _FakeQueueManager() client = _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(queue_manager=qm) with pytest.raises(GenerateTaskStoppedError): - _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) + _run(_runner(client, store), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] - assert [event.chunk.delta.message.content for event in chunk_events] == ["hello "] + agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] + assert chunk_events == [] + assert [event.chunk.delta.message.content for event in agent_message_events] == ["hello "] + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert len(rows) == 1 + assert rows[0].answer == "hello " assert client.cancelled_run_ids == ["fake-run-1"] @@ -662,6 +858,143 @@ def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypat assert rows[1].observation == "Knowledge base search results: browser skill" +def test_answer_suffix_trim_keeps_non_terminal_prefix(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + qm = _FakeQueueManager() + recorder = app_runner_module._AgentProcessRecorder( + dify_context=_dify_ctx(), + message_id="msg-1", + queue_manager=qm, # type: ignore[arg-type] + ) + + recorder.append_answer_text("intermediate final answer") + recorder.trim_answer_suffix("final answer") + + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert len(rows) == 1 + assert rows[0].answer == "intermediate " + + +def test_tool_call_part_binds_late_call_id_to_delta_row(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + qm = _FakeQueueManager() + recorder = app_runner_module._AgentProcessRecorder( + dify_context=_dify_ctx(), + message_id="msg-1", + queue_manager=qm, # type: ignore[arg-type] + ) + + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "part_delta", + "index": 0, + "delta": { + "part_delta_kind": "tool_call", + "tool_name_delta": "knowledge_base_search", + "args_delta": {"query": "browser"}, + }, + }, + ) + ) + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "part_start", + "index": 0, + "part": { + "part_kind": "tool-call", + "tool_name": "knowledge_base_search", + "args": {"query": "browser"}, + "tool_call_id": "tool-call-1", + }, + }, + ) + ) + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "function_tool_result", + "part": { + "part_kind": "tool-return", + "tool_name": "knowledge_base_search", + "content": "Knowledge base search results: browser skill", + "tool_call_id": "tool-call-1", + }, + }, + ) + ) + + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert len(rows) == 1 + assert rows[0].tool == "knowledge_base_search" + assert rows[0].tool_input == '{"query": "browser"}' + assert rows[0].observation == "Knowledge base search results: browser skill" + + +def test_thinking_after_tool_starts_new_snapshot_row(monkeypatch): + fake_session = _FakeDbSession() + monkeypatch.setattr(app_runner_module.db, "session", fake_session) + qm = _FakeQueueManager() + recorder = app_runner_module._AgentProcessRecorder( + dify_context=_dify_ctx(), + message_id="msg-1", + queue_manager=qm, # type: ignore[arg-type] + ) + + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "part_delta", + "index": 0, + "delta": { + "part_delta_kind": "thinking", + "content_delta": "The first thought.", + }, + }, + ) + ) + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "function_tool_call", + "part": { + "part_kind": "tool-call", + "tool_name": "shell_run", + "args": {"cmd": "date"}, + "tool_call_id": "tool-call-1", + }, + }, + ) + ) + recorder.handle_stream_event( + AgentBackendStreamInternalEvent( + run_id="run-1", + data={ + "event_kind": "part_delta", + "index": 0, + "delta": { + "part_delta_kind": "thinking", + "content_delta": "The next thought.", + }, + }, + ) + ) + + rows = sorted(fake_session.rows.values(), key=lambda row: row.position) + assert [row.thought for row in rows] == ["The first thought.", "", "The next thought."] + assert rows[0].id != rows[2].id + assert rows[1].tool == "shell_run" + assert rows[1].tool_input == '{"cmd": "date"}' + + def test_tool_result_without_call_id_matches_unique_open_tool_name(monkeypatch): fake_session = _FakeDbSession() monkeypatch.setattr(app_runner_module.db, "session", fake_session) @@ -743,31 +1076,6 @@ def test_debug_session_scope_can_reuse_conversation_across_config_snapshots(): assert store.saved[0][0].agent_config_snapshot_id is None -def test_stateless_run_uses_bounded_wait_and_does_not_save_session_state(): - prior = CompositorSessionSnapshot(layers=[]) - client = _BlockingRecordingFakeAgentBackendRunClient() - store = _FakeSessionStore(loaded=prior) - - _run_stateless(_runner(client, store)) - - assert client.request is not None - assert client.request.session_snapshot is prior - assert client.wait_calls == [("fake-run-1", app_runner_module.dify_config.APP_MAX_EXECUTION_TIME)] - assert client.stream_called is False - assert store.saved == [] - - -def test_stateless_run_raises_backend_error_on_failed_bounded_wait(): - client = _BlockingRecordingFakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED) - store = _FakeSessionStore() - - with pytest.raises(AgentBackendError): - _run_stateless(_runner(client, store)) - - assert client.wait_calls == [("fake-run-1", app_runner_module.dify_config.APP_MAX_EXECUTION_TIME)] - assert store.saved == [] - - def test_failed_run_raises_agent_backend_error(): client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED) store = _FakeSessionStore() @@ -792,11 +1100,11 @@ def test_stopped_task_cancels_agent_backend_run_and_skips_session_save(): assert store.saved == [] -def test_extract_answer_handles_plain_string_and_dict(): - assert AgentAppRunner._extract_answer(None) == "" - assert AgentAppRunner._extract_answer("plain text") == "plain text" - assert AgentAppRunner._extract_answer({"text": "hi"}) == "hi" - assert AgentAppRunner._extract_answer({"a": 1}) == '{"a": 1}' +def test_terminal_output_to_answer_handles_plain_string_and_dict(): + assert AgentAppRunner._terminal_output_to_answer(None) == "" + assert AgentAppRunner._terminal_output_to_answer("plain text") == "plain text" + assert AgentAppRunner._terminal_output_to_answer({"text": "hi"}) == "hi" + assert AgentAppRunner._terminal_output_to_answer({"a": 1}) == '{"a": 1}' def test_ask_human_pauses_turn_creates_form_and_persists_correlation(): @@ -827,6 +1135,22 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation(): assert store.saved[0][5] == "fake-ask-human-1" +def test_delete_on_exit_deferred_tool_marks_session_cleaned_and_raises_error(): + client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.PAUSED) + store = _FakeSessionStore() + store.mark_cleaned = MagicMock(side_effect=RuntimeError("cleanup failed")) # type: ignore[method-assign] + qm = _FakeQueueManager() + runner = _runner(client, store) + runner._pause_for_ask_human = MagicMock() + + with pytest.raises(AgentBackendError, match="finalization cannot pause for human input"): + _run(runner, qm, agent_runtime_exit_intent="delete") + + runner._pause_for_ask_human.assert_not_called() + assert store.saved == [] + store.mark_cleaned.assert_called_once() + + def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch): # ENG-638: a turn that runs while a pending form is answered threads the # human's reply into the request as deferred_tool_results. diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 9828216f3b8..b3ea05b8a87 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -57,7 +57,6 @@ class TestBuildForAgentApp: "llm", ] assert "workflow_node_job_prompt" not in names - assert request.purpose == "agent_app" # Agent App keeps layers alive across turns by default. assert request.on_exit.default.value == "suspend" @@ -143,6 +142,7 @@ def _ctx( *, query: str = "hello", agent_config_version_kind: str = "snapshot", + suspend_on_exit: bool = True, ) -> AgentAppRuntimeBuildContext: dify_context = SimpleNamespace( tenant_id="tenant-1", @@ -160,6 +160,7 @@ def _ctx( user_query=query, idempotency_key="msg-1", agent_config_version_kind=agent_config_version_kind, # type: ignore[arg-type] + suspend_on_exit=suspend_on_exit, ) @@ -185,7 +186,6 @@ class TestAgentAppRuntimeRequestBuilder: result = builder.build(_ctx(_soul_with_model())) req = result.request - assert req.purpose == "agent_app" names = [layer.name for layer in req.composition.layers] assert names == [ "agent_soul_prompt", @@ -207,10 +207,52 @@ class TestAgentAppRuntimeRequestBuilder: assert exec_ctx.config.user_from == "end-user" assert exec_ctx.config.invoke_from == "web-app" assert exec_ctx.config.agent_mode == "agent_app" + assert req.on_exit.default.value == "suspend" # credentials are redacted in the log-safe view. assert result.redacted_request["composition"]["layers"][-1]["config"]["credentials"] == "[REDACTED]" assert result.metadata["conversation_id"] == "conv-1" + def test_build_wraps_agent_soul_prompt_for_build_draft(self): + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="build_draft")) + + prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") + assert prompt_layer.config.prefix != _soul_with_model().prompt + assert prompt_layer.config.prefix.startswith("You are running in build mode.") + assert "```text\nYou are Iris.\n```" in prompt_layer.config.prefix + + def test_build_propagates_draft_version_kind_without_wrapping_prompt(self): + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(_soul_with_model(), agent_config_version_kind="draft")) + + prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") + execution_context = next( + layer for layer in result.request.composition.layers if layer.name == "execution_context" + ) + config_layer = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID) + + assert prompt_layer.config.prefix == "You are Iris." + assert execution_context.config.agent_config_version_kind == "draft" + assert config_layer.config.config_version.kind == "draft" + + def test_build_uses_delete_on_exit_when_requested(self): + builder = AgentAppRuntimeRequestBuilder( + credentials_provider=_FakeCredentialsProvider(), + dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] + ) + + result = builder.build(_ctx(_soul_with_model(), suspend_on_exit=False)) + + assert result.request.on_exit.default.value == "delete" + def test_build_includes_plugin_tools_layer_returned_by_injected_builder_for_draft(self): soul = _soul_with_model() soul.tools.dify_tools = [ diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py index 458b0a26dbe..fc9bf1b48e6 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py @@ -303,3 +303,46 @@ def test_load_active_session_for_conversation_isolates_other_conversations(): store.load_active_session_for_conversation(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-A") is not None ) + + +def test_list_active_sessions_for_conversation_returns_all_active_rows(): + store = AgentAppRuntimeSessionStore() + with session_factory.create_session() as session: + session.add( + AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + conversation_id="conv-1", + backend_run_id="run-1", + session_snapshot=_snapshot(messages=1).model_dump_json(), + composition_layer_specs='[{"name":"execution_context","type":"dify.execution_context","deps":{},"metadata":{},"config":{"tenant_id":"tenant-1"}},{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]', + status=AgentRuntimeSessionStatus.ACTIVE, + ) + ) + session.add( + AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-2", + agent_config_snapshot_id="snap-2", + conversation_id="conv-1", + backend_run_id="run-2", + session_snapshot=_snapshot(messages=2).model_dump_json(), + composition_layer_specs='[{"name":"execution_context","type":"dify.execution_context","deps":{},"metadata":{},"config":{"tenant_id":"tenant-1"}},{"name":"history","type":"pydantic_ai.history","deps":{},"metadata":{},"config":null}]', + status=AgentRuntimeSessionStatus.ACTIVE, + ) + ) + session.commit() + + loaded = store.list_active_sessions_for_conversation( + tenant_id="tenant-1", + app_id="app-1", + conversation_id="conv-1", + ) + + assert [session.scope.agent_id for session in loaded] == ["agent-2", "agent-1"] + assert all(session.scope.conversation_id == "conv-1" for session in loaded) diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py index 54e57d04d99..153157337e1 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py @@ -203,6 +203,7 @@ def _agent_thought() -> MessageAgentThought: tool="tool", tool_labels_str="{}", tool_input="input", + answer="answer", message_files="[]", ) thought.id = "thought" @@ -678,7 +679,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert agent_response.answer == "agent" assert isinstance(responses[-1], ErrorStreamResponse) assert isinstance(responses[-1].err, ValueError) - assert pipeline._task_state.llm_result.message.content == "annotatedagent" + assert pipeline._task_state.llm_result.message.content == "annotated" def test_agent_thought_to_stream_response_returns_payload(self, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) @@ -720,6 +721,7 @@ class TestEasyUiBasedGenerateTaskPipeline: assert response is not None assert response.id == "thought" + assert response.thought == "t" def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) @@ -735,6 +737,7 @@ class TestEasyUiBasedGenerateTaskPipeline: agent_thought = _agent_thought() agent_thought.thought = None + agent_thought.answer = None agent_thought.observation = None agent_thought.tool = None agent_thought.tool_input = None diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index 99448f7f5a9..407b5e59f7f 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -498,6 +498,37 @@ def test_agent_node_failed_run_without_session_store_skips_mark_cleaned(): assert "session_snapshot_cleaned_on_failure" not in agent_backend +def test_agent_node_failed_run_enqueues_backend_cleanup_before_local_retirement(monkeypatch): + store = FakeSessionStore() + store.loaded_session = StoredWorkflowAgentSession( + scope=_pending_session(CompositorSessionSnapshot(layers=[])).scope, + session_snapshot=CompositorSessionSnapshot(layers=[]), + backend_run_id="stored-run-1", + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + ) + queued_payloads: list[dict[str, object]] = [] + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.agent_node.cleanup_workflow_agent_runtime_session.delay", + lambda payload: queued_payloads.append(payload), + ) + + events = list(_node(scenario=FakeAgentBackendScenario.FAILED, session_store=store)._run()) + + assert len(events) == 1 + result = cast(StreamCompletedEvent, events[0]).node_run_result + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert store.cleaned[0][1] == "fake-run-1" + assert store.cleaned[0][0].workflow_run_id == "workflow-run-1" + assert store.cleaned[0][0].node_id == "agent-node" + assert len(queued_payloads) == 1 + assert ( + queued_payloads[0]["idempotency_key"] + == "tenant-1:workflow-run-1:agent-node:binding-1:workflow-agent-failure-cleanup:stored-run-1:fake-run-1" + ) + assert queued_payloads[0]["metadata"]["previous_agent_backend_run_id"] == "stored-run-1" + assert queued_payloads[0]["metadata"]["failed_agent_backend_run_id"] == "fake-run-1" + + def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot(): store = FakeSessionStore() node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 14210c5e553..9d0089e8e08 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -429,6 +429,8 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada assert "final_output.report" in output_description assert "never invent the `reference` value" in output_description assert "Do not call `final_output` before the upload command succeeds" in output_description + assert "accepted file-mapping shape and the returned `reference`" in output_description + assert "include the returned `download_url` in that reply" in output_description assert output_schema["properties"]["confidence"]["type"] == "number" assert output_schema["required"] == ["report"] assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2} @@ -728,7 +730,11 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): "top_k": 6, "score_threshold": 0.4, "reranking_model": {"provider": "cohere", "model": "rerank-v3"}, - "weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}}, + "weights": { + "weight_type": "weighted_score", + "vector_setting": {"vector_weight": 0.7}, + "keyword_setting": {"keyword_weight": 0.3}, + }, }, "metadata_filtering": { "mode": "manual", @@ -795,7 +801,10 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): "reranking_mode": "reranking_model", "reranking_enable": True, "reranking_model": {"provider": "cohere", "model": "rerank-v3"}, - "weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}}, + "weights": { + "vector_setting": {"vector_weight": 0.7}, + "keyword_setting": {"keyword_weight": 0.3}, + }, "model": None, }, "metadata_filtering": { @@ -1174,6 +1183,37 @@ def test_previous_node_file_output_uses_agent_stub_download_mapping_in_workflow_ } +def test_previous_node_file_mapping_strips_extra_fields_in_workflow_context(): + file_reference = build_file_reference(record_id="tool-file-1") + + class FileMappingVariablePool(FakeVariablePool): + def get(self, selector): + if list(selector) == ["previous-node", "report"]: + return SimpleNamespace( + value={ + "filename": "report.pdf", + "transfer_method": "tool_file", + "reference": file_reference, + "external": True, + } + ) + return super().get(selector) + + context = replace(_context(), variable_pool=FileMappingVariablePool()) + context.binding.node_job_config = WorkflowNodeJobConfig.model_validate( + { + "workflow_prompt": "Review {{#previous-node.report#}} before responding.", + } + ) + + result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context) + + assert _previous_node_prompt_payload(result, "previous-node.report") == { + "transfer_method": "tool_file", + "reference": file_reference, + } + + def test_scalar_previous_node_output_appears_in_workflow_context_section(): context = _context() context.binding.node_job_config = WorkflowNodeJobConfig.model_validate( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py index 67e7bfafca7..eda2962198e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_session_cleanup_layer.py @@ -1,15 +1,15 @@ -from datetime import UTC from typing import cast import pytest from agenton.compositor import CompositorSessionSnapshot from agenton.compositor.schemas import LayerSessionSnapshot from agenton.layers.base import LifecycleState -from dify_agent.protocol import CancelRunRequest, RunEvent, RunStatusResponse +from dify_agent.protocol import RuntimeLayerSpec -from clients.agent_backend import AgentBackendRunRequestBuilder, FakeAgentBackendRunClient, RuntimeLayerSpec -from clients.agent_backend.errors import AgentBackendHTTPError -from core.workflow.nodes.agent_v2.session_cleanup_layer import WorkflowAgentSessionCleanupLayer +from core.workflow.nodes.agent_v2.session_cleanup_layer import ( + WorkflowAgentSessionCleanupLayer, + build_workflow_agent_session_cleanup_layer, +) from core.workflow.nodes.agent_v2.session_store import ( StoredWorkflowAgentSession, WorkflowAgentRuntimeSessionStore, @@ -37,13 +37,21 @@ def _layer_snapshot(name: str) -> LayerSessionSnapshot: ) -def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> StoredWorkflowAgentSession: - """A typical stored session with prompt + execution_context + history + llm specs. +def _default_scope() -> WorkflowAgentSessionScope: + return WorkflowAgentSessionScope( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_run_id="workflow-run-1", + node_id="agent-node", + node_execution_id="node-exec-1", + binding_id="binding-1", + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + ) - The LLM layer is *not* in ``runtime_layer_specs`` because the cleanup - contract excludes credential-bearing plugin layers, but it *is* present in - the saved snapshot so the layer's filter logic gets exercised. - """ + +def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> StoredWorkflowAgentSession: return StoredWorkflowAgentSession( scope=scope, session_snapshot=CompositorSessionSnapshot( @@ -64,8 +72,6 @@ def _stored_session(scope: WorkflowAgentSessionScope, *, index: int = 1) -> Stor class FakeSessionStore: - """In-memory stand-in for ``WorkflowAgentRuntimeSessionStore``.""" - def __init__(self, *, stored: list[StoredWorkflowAgentSession] | None = None) -> None: self._stored = stored if stored is not None else [_stored_session(_default_scope())] self.list_calls: list[str] = [] @@ -79,69 +85,7 @@ class FakeSessionStore: self.cleaned.append((scope, backend_run_id)) -def _default_scope() -> WorkflowAgentSessionScope: - return WorkflowAgentSessionScope( - tenant_id="tenant-1", - app_id="app-1", - workflow_id="workflow-1", - workflow_run_id="workflow-run-1", - node_id="agent-node", - node_execution_id="node-exec-1", - binding_id="binding-1", - agent_id="agent-1", - agent_config_snapshot_id="snapshot-1", - ) - - -class _WaitableFakeAgentBackendRunClient(FakeAgentBackendRunClient): - """``FakeAgentBackendRunClient`` plus the ``wait_run`` hook the layer needs.""" - - def __init__( - self, - *, - run_id: str = "cleanup-run-1", - wait_status: str = "succeeded", - wait_error: str | None = None, - wait_raises: Exception | None = None, - ) -> None: - super().__init__(run_id=run_id) - self._wait_status = wait_status - self._wait_error = wait_error - self._wait_raises = wait_raises - self.wait_calls: list[tuple[str, float | None]] = [] - - def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse: - self.wait_calls.append((run_id, timeout_seconds)) - if self._wait_raises is not None: - raise self._wait_raises - from datetime import datetime - - return RunStatusResponse( - run_id=run_id, - status=cast(object, self._wait_status), # protocol Literal; cast keeps tests flexible - created_at=datetime(2026, 1, 1, tzinfo=UTC), - updated_at=datetime(2026, 1, 1, tzinfo=UTC), - error=self._wait_error, - ) - - # Inherit ``create_run`` from FakeAgentBackendRunClient; the missing protocol - # methods below are stub-only because the cleanup layer never calls them. - def cancel_run(self, run_id: str, request: CancelRunRequest | None = None): # pragma: no cover - del run_id, request - raise NotImplementedError - - def stream_events(self, run_id: str, *, after: str | None = None): # pragma: no cover - del run_id, after - if False: - yield cast(RunEvent, None) - - -def _build_layer( - *, - session_store: FakeSessionStore, - agent_backend_client: _WaitableFakeAgentBackendRunClient, - http_cleanup_supported: bool = True, -) -> WorkflowAgentSessionCleanupLayer: +def _build_layer(*, session_store: FakeSessionStore) -> WorkflowAgentSessionCleanupLayer: variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables(workflow_execution_id="workflow-run-1"), user_inputs={}, @@ -150,12 +94,7 @@ def _build_layer( runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) layer = WorkflowAgentSessionCleanupLayer( session_store=cast(WorkflowAgentRuntimeSessionStore, session_store), - request_builder=AgentBackendRunRequestBuilder(), - agent_backend_client=agent_backend_client, ) - # Tests opt in to the future HTTP-cleanup branch; the production default - # (False) is exercised by the dedicated tests below. - layer._HTTP_CLEANUP_SUPPORTED = http_cleanup_supported # type: ignore[reportPrivateUsage] layer.initialize(ReadOnlyGraphRuntimeStateWrapper(runtime_state), cast(CommandChannel, object())) return layer @@ -170,30 +109,22 @@ def _build_layer( ], ids=["succeeded", "partial_succeeded", "failed", "aborted"], ) -def test_cleanup_layer_triggers_cleanup_only_run_on_each_terminal_event(terminal_event): +def test_cleanup_layer_enqueues_cleanup_and_marks_cleaned_on_terminal_events(monkeypatch, terminal_event): session_store = FakeSessionStore() - agent_backend_client = _WaitableFakeAgentBackendRunClient() - layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client) + queued_payloads: list[dict[str, object]] = [] + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay", + lambda payload: queued_payloads.append(payload), + ) + layer = _build_layer(session_store=session_store) layer.on_event(terminal_event) assert session_store.list_calls == ["workflow-run-1"] - assert agent_backend_client.request is not None - # Cleanup composition replays the persisted (non-plugin) layer specs so the - # agent backend's snapshot-vs-composition name match succeeds. - layer_names = [layer.name for layer in agent_backend_client.request.composition.layers] - assert layer_names == ["workflow_node_job_prompt", "execution_context", "history"] - assert agent_backend_client.request.on_exit.default.value == "delete" - assert agent_backend_client.request.metadata["agent_backend_lifecycle"] == "session_cleanup" - # Snapshot is filtered to drop the plugin layer entry so names match the - # cleanup composition. - assert agent_backend_client.request.session_snapshot is not None - snapshot_names = [layer.name for layer in agent_backend_client.request.session_snapshot.layers] - assert snapshot_names == ["workflow_node_job_prompt", "execution_context", "history"] - # The layer waited for terminal status and the run succeeded, so the row - # is marked CLEANED with the cleanup run id. - assert agent_backend_client.wait_calls - assert session_store.cleaned == [(_default_scope(), "cleanup-run-1")] + assert len(queued_payloads) == 1 + assert queued_payloads[0]["metadata"]["workflow_run_id"] == "workflow-run-1" + assert queued_payloads[0]["metadata"]["previous_agent_backend_run_id"] == "agent-run-1" + assert session_store.cleaned == [(_default_scope(), "agent-run-1")] @pytest.mark.parametrize( @@ -204,91 +135,79 @@ def test_cleanup_layer_triggers_cleanup_only_run_on_each_terminal_event(terminal ], ids=["started", "paused"], ) -def test_cleanup_layer_ignores_non_terminal_events(non_terminal_event): +def test_cleanup_layer_ignores_non_terminal_events(monkeypatch, non_terminal_event): session_store = FakeSessionStore() - agent_backend_client = _WaitableFakeAgentBackendRunClient() - layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client) + queued_payloads: list[dict[str, object]] = [] + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay", + lambda payload: queued_payloads.append(payload), + ) + layer = _build_layer(session_store=session_store) layer.on_event(non_terminal_event) assert session_store.list_calls == [] - assert agent_backend_client.request is None + assert queued_payloads == [] assert session_store.cleaned == [] -def test_cleanup_layer_does_not_mark_cleaned_when_cleanup_run_fails(): - """Trap D: cleanup-only run goes ``run_failed`` (e.g. snapshot validation - error) — the layer must leave the row ACTIVE so it can be retried instead - of silently leaking suspended agent-backend layers.""" - session_store = FakeSessionStore() - agent_backend_client = _WaitableFakeAgentBackendRunClient( - wait_status="failed", - wait_error="snapshot mismatch", +def test_cleanup_layer_marks_cleaned_even_when_specs_are_missing(monkeypatch, caplog: pytest.LogCaptureFixture): + scope = _default_scope() + session_store = FakeSessionStore( + stored=[ + StoredWorkflowAgentSession( + scope=scope, + session_snapshot=CompositorSessionSnapshot(layers=[_layer_snapshot("history")]), + backend_run_id="legacy-run", + runtime_layer_specs=[], + ) + ] ) - layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client) + queued_payloads: list[dict[str, object]] = [] + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay", + lambda payload: queued_payloads.append(payload), + ) + layer = _build_layer(session_store=session_store) layer.on_event(GraphRunSucceededEvent(outputs={})) - assert agent_backend_client.wait_calls - assert session_store.cleaned == [] + assert queued_payloads == [] + assert session_store.cleaned == [(scope, "legacy-run")] + assert any("no runtime_layer_specs persisted" in record.message for record in caplog.records) -def test_cleanup_layer_does_not_mark_cleaned_when_wait_raises(): +def test_cleanup_layer_marks_cleaned_even_when_enqueue_fails(monkeypatch): session_store = FakeSessionStore() - agent_backend_client = _WaitableFakeAgentBackendRunClient( - wait_raises=AgentBackendHTTPError("boom", status_code=500, detail=None), + + def _explode(_payload: dict[str, object]) -> None: + raise RuntimeError("queue down") + + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay", + _explode, ) - layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client) + layer = _build_layer(session_store=session_store) layer.on_event(GraphRunSucceededEvent(outputs={})) - assert session_store.cleaned == [] - - -def test_cleanup_layer_marks_cleaned_locally_when_http_cleanup_disabled(): - """Production default: dify-agent has no cleanup-only run mode yet, so the - layer must retire the local row without issuing a doomed HTTP request that - would crash inside the agent backend's runner on the missing LLM layer.""" - session_store = FakeSessionStore() - agent_backend_client = _WaitableFakeAgentBackendRunClient() - layer = _build_layer( - session_store=session_store, - agent_backend_client=agent_backend_client, - http_cleanup_supported=False, - ) - - layer.on_event(GraphRunSucceededEvent(outputs={})) - - # No HTTP call goes out — the trap is avoided entirely. - assert agent_backend_client.request is None - assert agent_backend_client.wait_calls == [] - # Local row is still retired so a workflow loop cannot resume from stale state. assert session_store.cleaned == [(_default_scope(), "agent-run-1")] -def test_cleanup_layer_skips_sessions_without_persisted_specs(): - """Backwards-compatible safety net: a row written before A.1 landed has - no runtime_layer_specs, so cleanup would unavoidably hit the snapshot- - validation trap. The layer must skip such rows instead of issuing a - doomed request.""" - scope = _default_scope() - legacy_session = StoredWorkflowAgentSession( - scope=scope, - session_snapshot=CompositorSessionSnapshot(layers=[_layer_snapshot("history")]), - backend_run_id="legacy-run", - runtime_layer_specs=[], - ) - session_store = FakeSessionStore(stored=[legacy_session]) - agent_backend_client = _WaitableFakeAgentBackendRunClient() - layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client) +def test_cleanup_layer_does_not_raise_when_mark_cleaned_fails(monkeypatch): + session_store = FakeSessionStore() + + def _explode(*, scope: WorkflowAgentSessionScope, backend_run_id: str | None = None) -> None: + del scope, backend_run_id + raise RuntimeError("cleanup bookkeeping failed") + + monkeypatch.setattr(session_store, "mark_cleaned", _explode) + layer = _build_layer(session_store=session_store) layer.on_event(GraphRunSucceededEvent(outputs={})) - assert agent_backend_client.request is None - assert session_store.cleaned == [] - -def test_cleanup_layer_fans_out_to_every_active_session(): +def test_cleanup_layer_fans_out_to_every_active_session(monkeypatch): scopes = [ WorkflowAgentSessionScope( tenant_id="tenant-1", @@ -304,109 +223,34 @@ def test_cleanup_layer_fans_out_to_every_active_session(): for i in range(3) ] session_store = FakeSessionStore(stored=[_stored_session(scope, index=i) for i, scope in enumerate(scopes, 1)]) - agent_backend_client = _WaitableFakeAgentBackendRunClient(run_id="cleanup-run-many") - layer = _build_layer(session_store=session_store, agent_backend_client=agent_backend_client) + queued_payloads: list[dict[str, object]] = [] + monkeypatch.setattr( + "core.workflow.nodes.agent_v2.session_cleanup_layer.cleanup_workflow_agent_runtime_session.delay", + lambda payload: queued_payloads.append(payload), + ) + layer = _build_layer(session_store=session_store) layer.on_event(GraphRunSucceededEvent(outputs={})) - # One cleanup row per stored ACTIVE session, all marked cleaned with the - # backend run id returned by the agent backend client. + assert len(queued_payloads) == 3 assert [entry[0] for entry in session_store.cleaned] == scopes - assert {entry[1] for entry in session_store.cleaned} == {"cleanup-run-many"} -def test_cleanup_layer_warns_when_http_enabled_but_client_missing(caplog: pytest.LogCaptureFixture): - """The HTTP cleanup branch must defensively skip when no client was wired. - - This is the deployment-misconfig path: ``_HTTP_CLEANUP_SUPPORTED`` was - flipped to ``True`` but ``AGENT_BACKEND_BASE_URL`` is unset, so the - factory returned ``None``. The layer must not crash and must not silently - retire the row — the warning surfaces the misconfig. - """ - import logging - +def test_cleanup_layer_skips_when_workflow_run_id_missing(caplog: pytest.LogCaptureFixture): session_store = FakeSessionStore() - layer = WorkflowAgentSessionCleanupLayer( - session_store=cast(WorkflowAgentRuntimeSessionStore, session_store), - request_builder=AgentBackendRunRequestBuilder(), - agent_backend_client=None, - ) - layer._HTTP_CLEANUP_SUPPORTED = True # type: ignore[reportPrivateUsage] - variable_pool = VariablePool.from_bootstrap( - system_variables=build_system_variables(workflow_execution_id="workflow-run-1"), - user_inputs={}, - conversation_variables=[], - ) + variable_pool = VariablePool.from_bootstrap(system_variables={}, user_inputs={}, conversation_variables=[]) runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + layer = WorkflowAgentSessionCleanupLayer(session_store=cast(WorkflowAgentRuntimeSessionStore, session_store)) layer.initialize(ReadOnlyGraphRuntimeStateWrapper(runtime_state), cast(CommandChannel, object())) - with caplog.at_level(logging.WARNING): - layer.on_event(GraphRunSucceededEvent(outputs={})) - - assert session_store.cleaned == [] - assert any("no agent backend client is wired in" in record.message for record in caplog.records) - - -def test_cleanup_layer_skips_workflow_terminal_when_workflow_run_id_missing(caplog: pytest.LogCaptureFixture): - """``workflow_run_id`` is the keying field; without it the fanout cannot - target a row, so the layer logs a warning and bails.""" - import logging - - session_store = FakeSessionStore() - agent_backend_client = _WaitableFakeAgentBackendRunClient() - layer = WorkflowAgentSessionCleanupLayer( - session_store=cast(WorkflowAgentRuntimeSessionStore, session_store), - request_builder=AgentBackendRunRequestBuilder(), - agent_backend_client=agent_backend_client, - ) - # Bootstrap *without* a workflow_execution_id system variable. - variable_pool = VariablePool.from_bootstrap( - system_variables=build_system_variables(workflow_execution_id=""), - user_inputs={}, - conversation_variables=[], - ) - runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) - layer.initialize(ReadOnlyGraphRuntimeStateWrapper(runtime_state), cast(CommandChannel, object())) - - with caplog.at_level(logging.WARNING): - layer.on_event(GraphRunSucceededEvent(outputs={})) + layer.on_event(GraphRunSucceededEvent(outputs={})) assert session_store.list_calls == [] assert session_store.cleaned == [] assert any("workflow_run_id is missing" in record.message for record in caplog.records) -def test_build_workflow_agent_session_cleanup_layer_returns_layer_without_client_when_unconfigured( - monkeypatch, -): - """The production builder must pass ``None`` for the agent backend client - when neither AGENT_BACKEND_BASE_URL nor AGENT_BACKEND_USE_FAKE is set, so - that unit-test environments without backend config don't crash at runner - construction.""" - from configs import dify_config - from core.workflow.nodes.agent_v2.session_cleanup_layer import ( - build_workflow_agent_session_cleanup_layer, - ) - - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", None, raising=False) - monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", False, raising=False) - +def test_build_workflow_agent_session_cleanup_layer_returns_layer() -> None: layer = build_workflow_agent_session_cleanup_layer() - assert layer._agent_backend_client is None # type: ignore[reportPrivateUsage] - -def test_build_workflow_agent_session_cleanup_layer_returns_layer_with_fake_client(monkeypatch): - """With ``AGENT_BACKEND_USE_FAKE`` enabled the helper wires in the - deterministic fake client without needing a base_url.""" - from clients.agent_backend.fake_client import FakeAgentBackendRunClient - from configs import dify_config - from core.workflow.nodes.agent_v2.session_cleanup_layer import ( - build_workflow_agent_session_cleanup_layer, - ) - - monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", None, raising=False) - monkeypatch.setattr(dify_config, "AGENT_BACKEND_USE_FAKE", True, raising=False) - monkeypatch.setattr(dify_config, "AGENT_BACKEND_FAKE_SCENARIO", "success", raising=False) - - layer = build_workflow_agent_session_cleanup_layer() - assert isinstance(layer._agent_backend_client, FakeAgentBackendRunClient) # type: ignore[reportPrivateUsage] + assert isinstance(layer, WorkflowAgentSessionCleanupLayer) diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index f896a761904..22c3f4c5500 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -1,8 +1,11 @@ import json from datetime import UTC, datetime from types import SimpleNamespace +from unittest.mock import MagicMock import pytest +from agenton.compositor import CompositorSessionSnapshot +from dify_agent.protocol import RuntimeLayerSpec from sqlalchemy.exc import IntegrityError from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError @@ -3170,6 +3173,159 @@ class TestAgentAppBackingAgent: assert session.deleted == [] assert session.commits == 1 + def test_refresh_agent_app_debug_conversation_enqueues_cleanup_for_old_runtime_sessions( + self, 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, + app_id="app-1", + ) + mapping = SimpleNamespace(app_id="old-app", conversation_id="old-conversation") + stored_session = SimpleNamespace( + scope=SimpleNamespace( + tenant_id="tenant-1", + app_id="old-app", + conversation_id="old-conversation", + agent_id="agent-9", + agent_config_snapshot_id="snap-9", + ), + session_snapshot=CompositorSessionSnapshot(layers=[]), + backend_run_id="run-old", + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + ) + session = FakeSession(scalar=[agent, mapping]) + service = AgentRosterService(session) + cleanup_delay = MagicMock() + cleanup_store = MagicMock() + cleanup_store.list_active_sessions_for_conversation.return_value = [stored_session] + monkeypatch.setattr(roster_service, "AgentAppRuntimeSessionStore", lambda: cleanup_store) + monkeypatch.setattr(roster_service.cleanup_conversation_agent_runtime_session, "delay", cleanup_delay) + + conversation_id = service.refresh_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + + cleanup_store.list_active_sessions_for_conversation.assert_called_once_with( + tenant_id="tenant-1", + app_id="old-app", + conversation_id="old-conversation", + ) + cleanup_delay.assert_called_once() + payload = cleanup_delay.call_args.args[0] + assert payload["metadata"]["conversation_id"] == "old-conversation" + assert payload["metadata"]["agent_id"] == "agent-9" + assert ( + payload["idempotency_key"] == "tenant-1:agent-1:account-1:old-conversation:debug-session-cleanup:" + "agent-9:snap-9:run-old" + ) + cleanup_store.mark_cleaned.assert_called_once_with( + scope=stored_session.scope, + backend_run_id="run-old", + ) + assert mapping.app_id == "app-1" + assert mapping.conversation_id == conversation_id + + def test_refresh_agent_app_debug_conversation_marks_old_runtime_sessions_clean_when_enqueue_fails( + self, 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, + app_id="app-1", + ) + mapping = SimpleNamespace(app_id="old-app", conversation_id="old-conversation") + stored_session = SimpleNamespace( + scope=SimpleNamespace( + tenant_id="tenant-1", + app_id="old-app", + conversation_id="old-conversation", + agent_id="agent-9", + agent_config_snapshot_id="snap-9", + ), + session_snapshot=CompositorSessionSnapshot(layers=[]), + backend_run_id="run-old", + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + ) + session = FakeSession(scalar=[agent, mapping]) + service = AgentRosterService(session) + cleanup_store = MagicMock() + cleanup_store.list_active_sessions_for_conversation.return_value = [stored_session] + monkeypatch.setattr(roster_service, "AgentAppRuntimeSessionStore", lambda: cleanup_store) + monkeypatch.setattr( + roster_service.cleanup_conversation_agent_runtime_session, + "delay", + MagicMock(side_effect=RuntimeError("queue down")), + ) + + service.refresh_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + + cleanup_store.mark_cleaned.assert_called_once_with( + scope=stored_session.scope, + backend_run_id="run-old", + ) + + def test_refresh_agent_app_debug_conversation_ignores_mark_cleaned_failure(self, 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, + app_id="app-1", + ) + mapping = SimpleNamespace(app_id="old-app", conversation_id="old-conversation") + stored_session = SimpleNamespace( + scope=SimpleNamespace( + tenant_id="tenant-1", + app_id="old-app", + conversation_id="old-conversation", + agent_id="agent-9", + agent_config_snapshot_id="snap-9", + ), + session_snapshot=CompositorSessionSnapshot(layers=[]), + backend_run_id="run-old", + runtime_layer_specs=[RuntimeLayerSpec(name="history", type="pydantic_ai.history")], + ) + session = FakeSession(scalar=[agent, mapping]) + service = AgentRosterService(session) + cleanup_store = MagicMock() + cleanup_store.list_active_sessions_for_conversation.return_value = [stored_session] + cleanup_store.mark_cleaned.side_effect = RuntimeError("cleanup bookkeeping failed") + monkeypatch.setattr(roster_service, "AgentAppRuntimeSessionStore", lambda: cleanup_store) + monkeypatch.setattr(roster_service.cleanup_conversation_agent_runtime_session, "delay", MagicMock()) + + conversation_id = service.refresh_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + + assert mapping.app_id == "app-1" + assert mapping.conversation_id == conversation_id + assert session.commits == 1 + def test_duplicate_agent_app_copies_app_config_and_active_soul(self, monkeypatch: pytest.MonkeyPatch): source_config = SimpleNamespace( opening_statement="hello", diff --git a/api/tests/unit_tests/services/agent/test_prompt_mentions.py b/api/tests/unit_tests/services/agent/test_prompt_mentions.py index b65f8b6f410..6051c96b50b 100644 --- a/api/tests/unit_tests/services/agent/test_prompt_mentions.py +++ b/api/tests/unit_tests/services/agent/test_prompt_mentions.py @@ -243,9 +243,10 @@ def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig): assert expanded == ( "Read START/tenders and produce qna_report (file output; create the file locally, run " - "`dify-agent file upload `, then copy the returned AgentStubFileMapping JSON " - "as final_output.qna_report; do not call final_output before upload succeeds, and do not use " - "the local path, filename, URL, or a synthesized dify-file-ref as the reference); " + "`dify-agent file upload `, then set final_output.qna_report to a `tool_file` mapping " + "using the returned `reference`; if replying to the user in natural language, use the returned " + "`download_url`; do not call final_output before upload succeeds, and do not use the local path, " + "filename, URL, or a synthesized dify-file-ref as the reference); " "if unsure contact EMAIL · David Hayes." ) diff --git a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py index c36980f5829..f3052a4ab49 100644 --- a/api/tests/unit_tests/services/test_agent_app_sandbox_service.py +++ b/api/tests/unit_tests/services/test_agent_app_sandbox_service.py @@ -84,7 +84,12 @@ class FakeClient: self.locators.append(locator) self.calls.append(("upload", path)) return SandboxUploadResponse( - path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"} + path=path, + file={ + "transfer_method": "tool_file", + "reference": "dify-file-ref:file-1", + "download_url": "https://files.example/report.txt?token=1", + }, ) @@ -151,7 +156,11 @@ def test_agent_app_sandbox_service_upload_returns_download_url(monkeypatch: pyte assert store.scope == ("tenant-1", "app-1", "conv-1") assert captured == { "tenant_id": "tenant-1", - "file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + "file_mapping": { + "transfer_method": "tool_file", + "reference": "dify-file-ref:file-1", + "download_url": "https://files.example/report.txt?token=1", + }, } @@ -265,7 +274,11 @@ def test_workflow_sandbox_service_resolves_locator_and_returns_download_url( assert client.calls == [("upload", "report.txt")] assert captured == { "tenant_id": "tenant-1", - "file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + "file_mapping": { + "transfer_method": "tool_file", + "reference": "dify-file-ref:file-1", + "download_url": "https://files.example/report.txt?token=1", + }, } diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index 22c7514a522..552403e0d0b 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -310,22 +310,6 @@ class TestGenerate: assert result == {"result": "chat"} gen_spy.assert_called_once() - def test_stateless_agent_mode(self, mocker: MockerFixture): - gen_spy = mocker.patch( - "services.app_generate_service.AgentAppGenerator.generate_stateless", - return_value={"result": "stateless-agent"}, - ) - - result = AppGenerateService.generate_stateless_agent_app( - app_model=_make_app(AppMode.AGENT), - user=_make_user(), - args={"inputs": {}}, - invoke_from=InvokeFrom.SERVICE_API, - ) - - assert result == {"result": "stateless-agent"} - gen_spy.assert_called_once() - # -- ADVANCED_CHAT blocking --------------------------------------------- def test_advanced_chat_blocking(self, mocker: MockerFixture): workflow = _make_workflow() @@ -581,73 +565,6 @@ class TestGenerateBilling: # exit is called in finally block for non-streaming assert exit_calls == ["dummy-request-id"] - def test_stateless_agent_app_uses_billing_and_rate_limit_guardrails( - self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch - ): - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) - quota_charge = MagicMock() - reserve_mock = mocker.patch( - "services.app_generate_service.QuotaService.reserve", - return_value=quota_charge, - ) - exit_calls: list[str] = [] - - class _TrackingRateLimit(_DummyRateLimit): - def exit(self, request_id: str) -> None: - exit_calls.append(request_id) - - mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit) - gen_spy = mocker.patch( - "services.app_generate_service.AgentAppGenerator.generate_stateless", - return_value={"ok": True}, - ) - - result = AppGenerateService.generate_stateless_agent_app( - app_model=_make_app(AppMode.AGENT), - user=_make_user(), - args={"inputs": {}}, - invoke_from=InvokeFrom.SERVICE_API, - ) - - assert result == {"ok": True} - reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id") - quota_charge.commit.assert_called_once() - assert exit_calls == ["dummy-request-id"] - gen_spy.assert_called_once() - - def test_stateless_agent_app_failure_refunds_quota_and_exits_once( - self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch - ): - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) - quota_charge = MagicMock() - mocker.patch( - "services.app_generate_service.QuotaService.reserve", - return_value=quota_charge, - ) - exit_calls: list[str] = [] - - class _TrackingRateLimit(_DummyRateLimit): - def exit(self, request_id: str) -> None: - exit_calls.append(request_id) - - mocker.patch("services.app_generate_service.RateLimit", _TrackingRateLimit) - mocker.patch( - "services.app_generate_service.AgentAppGenerator.generate_stateless", - side_effect=RuntimeError("boom"), - ) - - with pytest.raises(RuntimeError, match="boom"): - AppGenerateService.generate_stateless_agent_app( - app_model=_make_app(AppMode.AGENT), - user=_make_user(), - args={"inputs": {}}, - invoke_from=InvokeFrom.SERVICE_API, - ) - - quota_charge.commit.assert_called_once() - quota_charge.refund.assert_called_once() - assert exit_calls == ["dummy-request-id"] - def test_blocking_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() diff --git a/api/tests/unit_tests/services/test_file_request_service.py b/api/tests/unit_tests/services/test_file_request_service.py index f5d1a59c17e..57abdeaa322 100644 --- a/api/tests/unit_tests/services/test_file_request_service.py +++ b/api/tests/unit_tests/services/test_file_request_service.py @@ -30,8 +30,9 @@ def test_request_download_url_builds_file_under_bound_scope( patch("services.file_request_service.bind_file_access_scope", return_value=nullcontext()) as bind_scope, patch.object(service, "_build_file", return_value=fake_file) as build_file, patch( - "services.file_request_service.file_helpers.resolve_file_url", return_value="https://files.example.com/x" - ), + "services.file_request_service.file_helpers.resolve_file_url", + return_value="https://files.example.com/x", + ) as resolve_file_url, ): result = service.request_download_url( tenant_id="tenant-1", @@ -51,6 +52,7 @@ def test_request_download_url_builds_file_under_bound_scope( build_file.assert_called_once_with( mapping={"transfer_method": "tool_file", "reference": reference}, tenant_id="tenant-1" ) + resolve_file_url.assert_called_once_with(fake_file, for_external=True) assert result.filename == "report.pdf" assert result.mime_type == "application/pdf" assert result.size == 123 diff --git a/api/tests/unit_tests/tasks/test_agent_backend_session_cleanup_task.py b/api/tests/unit_tests/tasks/test_agent_backend_session_cleanup_task.py new file mode 100644 index 00000000000..f7bc8a4a9ac --- /dev/null +++ b/api/tests/unit_tests/tasks/test_agent_backend_session_cleanup_task.py @@ -0,0 +1,51 @@ +import logging + +from agenton.compositor import CompositorSessionSnapshot + +from clients.agent_backend.session_cleanup import ( + AgentBackendSessionCleanupPayload, + AgentBackendSessionCleanupResult, +) +from tasks import agent_backend_session_cleanup_task as cleanup_task_module + + +def _payload_dict() -> dict[str, object]: + return AgentBackendSessionCleanupPayload( + session_snapshot=CompositorSessionSnapshot(layers=[]), + runtime_layer_specs=[], + metadata={"tenant_id": "tenant-1", "app_id": "app-1"}, + ).model_dump(mode="json") + + +def test_run_cleanup_task_logs_info_for_skipped_result(monkeypatch, caplog): + monkeypatch.setattr(cleanup_task_module, "_create_agent_backend_client", lambda: object()) + monkeypatch.setattr( + cleanup_task_module, + "cleanup_agent_backend_session", + lambda **kwargs: AgentBackendSessionCleanupResult.skipped("missing_runtime_layer_specs"), + ) + + with caplog.at_level(logging.INFO, logger="tasks.agent_backend_session_cleanup_task"): + cleanup_task_module._run_cleanup_task(_payload_dict()) + + assert "Agent backend session cleanup skipped" in caplog.text + assert "missing_runtime_layer_specs" in caplog.text + + +def test_run_cleanup_task_logs_warning_for_failed_result(monkeypatch, caplog): + monkeypatch.setattr(cleanup_task_module, "_create_agent_backend_client", lambda: object()) + monkeypatch.setattr( + cleanup_task_module, + "cleanup_agent_backend_session", + lambda **kwargs: AgentBackendSessionCleanupResult.failed( + "backend exploded", + cleanup_run_id="cleanup-run-1", + ), + ) + + with caplog.at_level(logging.WARNING, logger="tasks.agent_backend_session_cleanup_task"): + cleanup_task_module._run_cleanup_task(_payload_dict()) + + assert "Agent backend session cleanup failed" in caplog.text + assert "backend exploded" in caplog.text + assert "cleanup-run-1" in caplog.text diff --git a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py index 9fc94547468..1e4b37612ba 100644 --- a/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/unit_tests/tasks/test_remove_app_and_related_data_task.py @@ -1,10 +1,16 @@ import logging +from collections.abc import Generator from unittest.mock import MagicMock, call, patch import pytest +from agenton.compositor import CompositorSessionSnapshot +from sqlalchemy import delete, select +from core.db.session_factory import session_factory from libs.archive_storage import ArchiveStorageNotConfiguredError +from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus from tasks.remove_app_and_related_data_task import ( + _cleanup_active_agent_runtime_sessions_for_app, _delete_app_stars, _delete_app_workflow_archive_logs, _delete_archived_workflow_run_files, @@ -14,6 +20,29 @@ from tasks.remove_app_and_related_data_task import ( ) +@pytest.fixture(autouse=True) +def _create_agent_runtime_sessions_table() -> Generator[None, None, None]: + engine = session_factory.get_session_maker().kw["bind"] + AgentRuntimeSession.__table__.create(bind=engine, checkfirst=True) + yield + with session_factory.create_session() as session: + session.execute(delete(AgentRuntimeSession)) + session.commit() + AgentRuntimeSession.__table__.drop(bind=engine, checkfirst=True) + + +def _runtime_session_specs_json() -> str: + return ( + '[{"name":"execution_context","type":"dify.execution_context","deps":{},"metadata":{},' + '"config":{"tenant_id":"tenant-1"}},{"name":"history","type":"pydantic_ai.history","deps":{},' + '"metadata":{},"config":null}]' + ) + + +def _snapshot_json() -> str: + return CompositorSessionSnapshot(layers=[]).model_dump_json() + + class TestDeleteDraftVariablesBatch: def test_delete_draft_variables_batch_invalid_batch_size(self): """Test that invalid batch size raises ValueError.""" @@ -149,3 +178,215 @@ class TestDeleteArchivedWorkflowRunFiles: storage.list_objects.assert_called_once_with("tenant-1/app_id=app-1/") storage.delete_object.assert_has_calls([call("key-1"), call("key-2")], any_order=False) assert "Deleted 2 archive objects for app app-1" in caplog.text + + +class TestCleanupActiveAgentRuntimeSessionsForApp: + @patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session") + @patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session") + def test_enqueues_cleanup_for_active_rows_and_marks_rows_cleaned( + self, + mock_conversation_cleanup, + mock_workflow_cleanup, + ): + with session_factory.create_session() as session: + session.add( + AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + conversation_id="conv-1", + backend_run_id="run-conv", + session_snapshot=_snapshot_json(), + composition_layer_specs=_runtime_session_specs_json(), + status=AgentRuntimeSessionStatus.ACTIVE, + ) + ) + session.add( + AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN, + agent_id="agent-2", + workflow_id="wf-1", + workflow_run_id="wf-run-1", + node_id="node-1", + backend_run_id="run-wf", + session_snapshot=_snapshot_json(), + composition_layer_specs=_runtime_session_specs_json(), + status=AgentRuntimeSessionStatus.ACTIVE, + ) + ) + session.add( + AgentRuntimeSession( + tenant_id="tenant-1", + app_id="other-app", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-3", + conversation_id="conv-2", + backend_run_id="run-other", + session_snapshot=_snapshot_json(), + composition_layer_specs=_runtime_session_specs_json(), + status=AgentRuntimeSessionStatus.ACTIVE, + ) + ) + session.commit() + + _cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1", batch_size=1) + + assert mock_conversation_cleanup.delay.call_count == 1 + assert mock_workflow_cleanup.delay.call_count == 1 + conversation_payload = mock_conversation_cleanup.delay.call_args.args[0] + workflow_payload = mock_workflow_cleanup.delay.call_args.args[0] + assert conversation_payload["metadata"]["conversation_id"] == "conv-1" + assert workflow_payload["metadata"]["workflow_run_id"] == "wf-run-1" + assert conversation_payload["idempotency_key"].startswith("tenant-1:app-1:conv-1:agent-1:app-delete-cleanup:") + assert workflow_payload["idempotency_key"].startswith( + "tenant-1:app-1:wf-run-1:node-1:agent-2:app-delete-cleanup:" + ) + with session_factory.create_session() as session: + app_rows = session.scalars( + select(AgentRuntimeSession).where( + AgentRuntimeSession.tenant_id == "tenant-1", + AgentRuntimeSession.app_id == "app-1", + ) + ).all() + assert {row.status for row in app_rows} == {AgentRuntimeSessionStatus.CLEANED} + other_row = session.scalar( + select(AgentRuntimeSession).where( + AgentRuntimeSession.tenant_id == "tenant-1", + AgentRuntimeSession.app_id == "other-app", + ) + ) + assert other_row is not None + assert other_row.status == AgentRuntimeSessionStatus.ACTIVE + + @patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session") + @patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session") + def test_marks_rows_cleaned_even_when_enqueue_fails( + self, + mock_conversation_cleanup, + mock_workflow_cleanup, + ): + mock_conversation_cleanup.delay.side_effect = RuntimeError("queue down") + with session_factory.create_session() as session: + session.add( + AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-1", + conversation_id="conv-1", + backend_run_id="run-conv", + session_snapshot=_snapshot_json(), + composition_layer_specs=_runtime_session_specs_json(), + status=AgentRuntimeSessionStatus.ACTIVE, + ) + ) + session.add( + AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.WORKFLOW_RUN, + agent_id="agent-2", + workflow_id="wf-1", + workflow_run_id="wf-run-1", + node_id="node-1", + backend_run_id="run-wf", + session_snapshot=_snapshot_json(), + composition_layer_specs=_runtime_session_specs_json(), + status=AgentRuntimeSessionStatus.ACTIVE, + ) + ) + session.commit() + + _cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1") + + mock_conversation_cleanup.delay.assert_called_once() + mock_workflow_cleanup.delay.assert_called_once() + with session_factory.create_session() as session: + rows = session.scalars( + select(AgentRuntimeSession).where( + AgentRuntimeSession.tenant_id == "tenant-1", + AgentRuntimeSession.app_id == "app-1", + ) + ).all() + assert rows + assert {row.status for row in rows} == {AgentRuntimeSessionStatus.CLEANED} + + @patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session") + @patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session") + def test_uses_row_identity_to_keep_distinct_app_delete_cleanup_jobs_distinct( + self, + mock_conversation_cleanup, + mock_workflow_cleanup, + ): + del mock_workflow_cleanup + with session_factory.create_session() as session: + first = AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-1", + agent_config_snapshot_id="snap-1", + conversation_id="conv-1", + backend_run_id="run-conv-1", + session_snapshot=_snapshot_json(), + composition_layer_specs=_runtime_session_specs_json(), + status=AgentRuntimeSessionStatus.ACTIVE, + ) + second = AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-1", + agent_config_snapshot_id="snap-2", + conversation_id="conv-1", + backend_run_id="run-conv-2", + session_snapshot=_snapshot_json(), + composition_layer_specs=_runtime_session_specs_json(), + status=AgentRuntimeSessionStatus.ACTIVE, + ) + session.add(first) + session.add(second) + session.commit() + + _cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1") + + assert mock_conversation_cleanup.delay.call_count == 2 + payloads = [queued_call.args[0] for queued_call in mock_conversation_cleanup.delay.call_args_list] + assert payloads[0]["idempotency_key"] != payloads[1]["idempotency_key"] + assert payloads[0]["idempotency_key"].endswith(first.id) + assert payloads[1]["idempotency_key"].endswith(second.id) + + @patch("tasks.remove_app_and_related_data_task.cleanup_workflow_agent_runtime_session") + @patch("tasks.remove_app_and_related_data_task.cleanup_conversation_agent_runtime_session") + def test_marks_empty_runtime_layer_specs_rows_clean_without_enqueue( + self, + mock_conversation_cleanup, + mock_workflow_cleanup, + ): + del mock_workflow_cleanup + with session_factory.create_session() as session: + row = AgentRuntimeSession( + tenant_id="tenant-1", + app_id="app-1", + owner_type=AgentRuntimeSessionOwnerType.CONVERSATION, + agent_id="agent-1", + conversation_id="conv-1", + backend_run_id="run-no-specs", + session_snapshot=_snapshot_json(), + composition_layer_specs="[]", + status=AgentRuntimeSessionStatus.ACTIVE, + ) + session.add(row) + session.commit() + + _cleanup_active_agent_runtime_sessions_for_app("tenant-1", "app-1") + + mock_conversation_cleanup.delay.assert_not_called() + with session_factory.create_session() as session: + stored_row = session.scalar(select(AgentRuntimeSession).where(AgentRuntimeSession.id == row.id)) + assert stored_row is not None + assert stored_row.status == AgentRuntimeSessionStatus.CLEANED diff --git a/dify-agent/src/dify_agent/adapters/llm/model.py b/dify-agent/src/dify_agent/adapters/llm/model.py index 59f54de2271..e48ccc8bb17 100644 --- a/dify-agent/src/dify_agent/adapters/llm/model.py +++ b/dify-agent/src/dify_agent/adapters/llm/model.py @@ -334,7 +334,7 @@ def _map_model_response_to_prompt_message( content_parts: list[PromptMessageContentUnionTypes] = [] tool_calls: list[AssistantPromptMessage.ToolCall] = [] - for part in message.parts: + for index, part in enumerate(message.parts): if isinstance(part, TextPart): if part.content: content_parts.append(TextPromptMessageContent(data=part.content)) @@ -346,7 +346,7 @@ def _map_model_response_to_prompt_message( elif isinstance(part, ToolCallPart): tool_calls.append( AssistantPromptMessage.ToolCall( - id=part.tool_call_id or f"tool-call-{part.tool_name}", + id=part.tool_call_id or f"tool-call-{index}-{part.tool_name}", type="function", function=AssistantPromptMessage.ToolCall.ToolCallFunction( name=part.tool_name, diff --git a/dify-agent/src/dify_agent/agent_stub/cli/_files.py b/dify-agent/src/dify_agent/agent_stub/cli/_files.py index 467e7b7e2b5..5e3635fde14 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/_files.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/_files.py @@ -5,7 +5,7 @@ from __future__ import annotations import mimetypes from dataclasses import dataclass from pathlib import Path -from typing import ClassVar, Literal, cast +from typing import ClassVar, Literal, Protocol, cast from pydantic import BaseModel, ConfigDict, ValidationError @@ -21,11 +21,16 @@ from dify_agent.agent_stub.client import ( from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping, is_canonical_dify_file_reference +class _AgentStubFileDownloadResponse(Protocol): + download_url: str + + class UploadedToolFileMapping(BaseModel): """Canonical Agent output mapping returned by ``dify-agent file upload``.""" transfer_method: Literal["tool_file"] = "tool_file" reference: str + download_url: str model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") @@ -39,9 +44,9 @@ class DownloadedFileResult: @dataclass(frozen=True, slots=True) class UploadedToolFileResource: - """Lower-level upload result carrying both public mapping and ToolFile id.""" + """Lower-level upload result carrying the internal mapping and ToolFile id.""" - mapping: UploadedToolFileMapping + mapping: AgentStubFileMapping tool_file_id: str @@ -49,11 +54,22 @@ def upload_file_from_environment(*, path: str) -> UploadedToolFileMapping: """Upload one sandbox-local file through the Agent Stub control plane. The signed upload data-plane response must carry the Dify-generated - ``reference`` for the new ``ToolFile`` so the sandbox can return the - canonical Agent output file mapping without synthesizing reference format. + ``reference`` for the new ``ToolFile``. The helper then resolves the same + mapping through the signed download-request control plane so the public CLI + output includes a ready-to-share external ``download_url``. """ - return upload_tool_file_resource_from_environment(path=path).mapping + resource = upload_tool_file_resource_from_environment(path=path) + reference = resource.mapping.reference + if not isinstance(reference, str) or not reference: + raise AgentStubTransferError("signed file upload response is missing reference") + environment = read_agent_stub_environment() + download_url = _request_uploaded_tool_file_download_url( + url=environment.url, + auth_jwe=environment.auth_jwe, + reference=reference, + ) + return UploadedToolFileMapping(reference=reference, download_url=download_url) def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFileResource: @@ -62,6 +78,8 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile This lower-level helper backs ``drive push``. The signed upload data-plane response must include both the canonical Dify ``reference`` used by public CLI output and the raw ToolFile ``id`` required by drive commit payloads. + It intentionally stops after the upload allocation so internal flows that + only need the ToolFile identity do not pay for signed download enrichment. Raises: AgentStubValidationError: if ``path`` does not resolve to a local file. @@ -92,7 +110,11 @@ def upload_tool_file_resource_from_environment(*, path: str) -> UploadedToolFile file_obj=file_obj, mimetype=mime_type, ) - return _normalize_uploaded_tool_file_resource(payload) + reference, tool_file_id = _normalize_uploaded_tool_file_payload(payload) + return UploadedToolFileResource( + mapping=AgentStubFileMapping(transfer_method="tool_file", reference=reference), + tool_file_id=tool_file_id, + ) def download_file_from_environment( @@ -164,7 +186,7 @@ def _build_download_mapping( raise AgentStubValidationError("invalid file download arguments") from exc -def _normalize_uploaded_tool_file_resource(payload: dict[str, object]) -> UploadedToolFileResource: +def _normalize_uploaded_tool_file_payload(payload: dict[str, object]) -> tuple[str, str]: reference = payload.get("reference") if not isinstance(reference, str) or not reference: raise AgentStubTransferError("signed file upload response is missing reference") @@ -173,10 +195,22 @@ def _normalize_uploaded_tool_file_resource(payload: dict[str, object]) -> Upload tool_file_id = payload.get("id") if not isinstance(tool_file_id, str) or not tool_file_id: raise AgentStubTransferError("signed file upload response is missing id") - return UploadedToolFileResource( - mapping=UploadedToolFileMapping(reference=reference), - tool_file_id=tool_file_id, + return reference, tool_file_id + + +def _request_uploaded_tool_file_download_url(*, url: str, auth_jwe: str, reference: str) -> str: + download_request = cast( + _AgentStubFileDownloadResponse, + request_agent_stub_file_download_sync( + url=url, + auth_jwe=auth_jwe, + file=AgentStubFileMapping(transfer_method="tool_file", reference=reference), + ), ) + download_url = download_request.download_url + if not isinstance(download_url, str) or not download_url: + raise AgentStubTransferError("signed file download response is missing download_url") + return download_url def _deduplicate_destination_path(path: Path) -> Path: diff --git a/dify-agent/src/dify_agent/agent_stub/cli/main.py b/dify-agent/src/dify_agent/agent_stub/cli/main.py index d815a67dd20..ee00059d1e5 100644 --- a/dify-agent/src/dify_agent/agent_stub/cli/main.py +++ b/dify-agent/src/dify_agent/agent_stub/cli/main.py @@ -24,6 +24,13 @@ _CONFIG_MANIFEST_STDOUT_EXCLUDE = { "skills": {"items": {"__all__": {"hash"}}}, "files": {"items": {"__all__": {"hash"}}}, } +_FILE_DOWNLOAD_TRANSFER_METHOD_HELP = ( + "File mapping transfer_method: local_file, tool_file, datasource_file, or remote_url." +) +_FILE_DOWNLOAD_REFERENCE_OR_URL_HELP = ( + "File mapping reference or URL. Use dify-file-ref:... with local_file, tool_file, or datasource_file; " + "use https://... with remote_url." +) app = typer.Typer( @@ -76,16 +83,18 @@ def upload(path: str = typer.Argument(..., metavar="PATH")) -> None: @file_app.command("download") def download( - transfer_method: str | None = typer.Argument(None, metavar="TRANSFER_METHOD"), - reference_or_url: str | None = typer.Argument(None, metavar="REFERENCE_OR_URL"), - mapping: str | None = typer.Option(None, "--mapping", help="Download one file from a mapping JSON object."), + transfer_method: str = typer.Argument(..., metavar="TRANSFER_METHOD", help=_FILE_DOWNLOAD_TRANSFER_METHOD_HELP), + reference_or_url: str = typer.Argument( + ..., + metavar="REFERENCE_OR_URL", + help=_FILE_DOWNLOAD_REFERENCE_OR_URL_HELP, + ), local_dir: str | None = typer.Option(None, "--to", help="Local directory for the downloaded file."), ) -> None: """Download one workflow file mapping into the local sandbox directory.""" _run_file_download( transfer_method=transfer_method, reference_or_url=reference_or_url, - mapping=mapping, local_dir=local_dir, ) @@ -361,9 +370,8 @@ def _run_file_upload(*, path: str) -> None: def _run_file_download( *, - transfer_method: str | None, - reference_or_url: str | None, - mapping: str | None, + transfer_method: str, + reference_or_url: str, local_dir: str | None, ) -> None: env_module = _env_module() @@ -373,7 +381,6 @@ def _run_file_download( response = files_module.download_file_from_environment( transfer_method=transfer_method, reference_or_url=reference_or_url, - mapping=mapping, local_dir=local_dir, ) except env_module.MissingAgentStubEnvironmentError as exc: diff --git a/dify-agent/src/dify_agent/layers/_agent_file_cli_help.py b/dify-agent/src/dify_agent/layers/_agent_file_cli_help.py new file mode 100644 index 00000000000..50deef3d851 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/_agent_file_cli_help.py @@ -0,0 +1,7 @@ +"""Shared model-facing guidance for Agent Stub file CLI usage in prompt surfaces.""" + +AGENT_FILE_UPLOAD_REPLY_HINT = ( + "When you want to provide a generated or sandbox-local file to the user in a " + "natural-language reply, run the installed CLI command `dify-agent file upload PATH` and include the returned " + "`download_url` so the user can open or download the file." +) diff --git a/dify-agent/src/dify_agent/layers/config/configs.py b/dify-agent/src/dify_agent/layers/config/configs.py index c3d20c878f1..5f8eae360eb 100644 --- a/dify-agent/src/dify_agent/layers/config/configs.py +++ b/dify-agent/src/dify_agent/layers/config/configs.py @@ -59,6 +59,9 @@ class DifyConfigLayerConfig(LayerConfig): class DifyConfigRuntimeState(BaseModel): """Serializable config-layer values computed once during context entry. + ``config_cli_help`` stores pre-rendered shell-visible ``dify-agent`` help snippets + for config commands and file upload/download commands. + The ``push_spec_*`` fields are compatibility leftovers from the removed root JSON-spec config mutation workflow. This change keeps them in the runtime-state schema to avoid snapshot churn, but new code should treat them as inert diff --git a/dify-agent/src/dify_agent/layers/config/layer.py b/dify-agent/src/dify_agent/layers/config/layer.py index d914f599efb..6a4a5dea4db 100644 --- a/dify-agent/src/dify_agent/layers/config/layer.py +++ b/dify-agent/src/dify_agent/layers/config/layer.py @@ -11,6 +11,7 @@ from typing_extensions import Self, override from agenton.layers import LayerDeps, PlainLayer from dify_agent.agent_stub.cli.main import render_agent_stub_cli_help +from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT from dify_agent.layers.config.configs import ( DIFY_CONFIG_LAYER_TYPE_ID, DifyConfigLayerConfig, @@ -18,14 +19,19 @@ from dify_agent.layers.config.configs import ( ) from dify_agent.layers.shell.layer import DifyShellLayer -_CONFIG_CONTEXT_HEADING = "Agent config context from the current Agent Soul:" -_CONFIG_CONTEXT_COMMAND = "$ dify-agent config manifest" -_CONFIG_CLI_USAGE_PROMPT = """Agent config CLI usage is available inside shell jobs. The command help below is generated -from the same `dify-agent` CLI definitions available in shell jobs. +_CONFIG_CONTEXT_HEADING = "Current Agent config manifest for this run:" +_CONFIG_CONTEXT_COMMAND = "dify-agent config manifest" +_CONFIG_CLI_USAGE_PROMPT = """`dify-agent` is an installed CLI tool in the shell environment. Use it directly in shell_run scripts. -Local edits to config files, skills, env, or notes are not saved by themselves. Config changes are saved only by a -matching resource mutation command. Those commands are available only when the Agent config context reports -`config_version.kind` as `build_draft` and `config_version.writable` as true.""" +The command outputs below are generated from the `dify-agent` CLI available in this run. Use them as the source of truth +for command names, arguments, and options. + +Config persistence rules: + +- Local shell edits to config files, skills, env, or notes are not saved by themselves. +- To persist an Agent config change, run the matching `dify-agent config ...` mutation command. +- Mutation commands are available only when the manifest shows `config_version.kind` as `build_draft` and + `config_version.writable` as true.""" _CONFIG_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = { "dify-agent config --help": ("config",), "dify-agent config manifest --help": ("config", "manifest"), @@ -41,6 +47,10 @@ _CONFIG_CLI_MUTATION_HELP_COMMANDS: dict[str, tuple[str, ...]] = { "dify-agent config skills push --help": ("config", "skills", "push"), "dify-agent config skills delete --help": ("config", "skills", "delete"), } +_AGENT_FILE_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = { + "dify-agent file upload --help": ("file", "upload"), + "dify-agent file download --help": ("file", "download"), +} _CONFIG_CONTEXT_EXCLUDE = {"mentioned_skill_names": True, "mentioned_file_names": True} @@ -91,6 +101,7 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf command_paths = dict(_CONFIG_CLI_HELP_COMMANDS) if self._config_writable: command_paths.update(_CONFIG_CLI_MUTATION_HELP_COMMANDS) + command_paths.update(_AGENT_FILE_CLI_HELP_COMMANDS) self.runtime_state.config_context_json = self._format_config_context_json() self.runtime_state.config_cli_help = { command: render_agent_stub_cli_help(args) for command, args in command_paths.items() @@ -107,15 +118,22 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf output = self.runtime_state.pulled_skill_outputs.get(name) if output is None: continue - loaded_skill_sections.append(f"Name: {name}\nPull output:\n{output}") + command = f"dify-agent config skills pull {shlex.quote(name)}" + loaded_skill_sections.append( + f"Name: {name}\nPull command output for this run:\n{_format_command_output(command, output)}" + ) if loaded_skill_sections: sections.append("Loaded mentioned skills:\n\n" + "\n\n".join(loaded_skill_sections)) - mentioned_file_sections = [ - f"Name: {name}\nPull output:\n{self.runtime_state.pulled_file_outputs[name]}" - for name in self.config.mentioned_file_names - if name in self.runtime_state.pulled_file_outputs - ] + mentioned_file_sections = [] + for name in self.config.mentioned_file_names: + output = self.runtime_state.pulled_file_outputs.get(name) + if output is None: + continue + command = f"dify-agent config files pull {shlex.quote(name)}" + mentioned_file_sections.append( + f"Name: {name}\nPull command output for this run:\n{_format_command_output(command, output)}" + ) if mentioned_file_sections: sections.append("Mentioned files pulled locally:\n\n" + "\n\n".join(mentioned_file_sections)) @@ -125,11 +143,14 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf sections: list[str] = [] if self.runtime_state.config_context_json: sections.append( - f"{_CONFIG_CONTEXT_COMMAND}\n{_CONFIG_CONTEXT_HEADING}\n{self.runtime_state.config_context_json}" + f"{_CONFIG_CONTEXT_HEADING}\n" + f"{_format_command_output(_CONFIG_CONTEXT_COMMAND, self.runtime_state.config_context_json)}" ) usage_lines = [_CONFIG_CLI_USAGE_PROMPT] if cli_help := self._format_config_cli_help(): usage_lines.append(cli_help) + if file_cli_help := self._format_agent_file_cli_help(): + usage_lines.append(file_cli_help) sections.append("\n".join(usage_lines)) return "\n\n".join(section for section in sections if section) @@ -142,13 +163,27 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf if self._config_writable: commands.extend(_CONFIG_CLI_MUTATION_HELP_COMMANDS) command_sections = [ - f"$ {command}\n{self.runtime_state.config_cli_help[command]}" + _format_command_output(command, self.runtime_state.config_cli_help[command]) for command in commands if command in self.runtime_state.config_cli_help ] if not command_sections: return "" - return "Agent config CLI help:\n" + "\n\n".join(command_sections) + return "Agent config CLI reference for installed `dify-agent`:\n" + "\n\n".join(command_sections) + + def _format_agent_file_cli_help(self) -> str: + command_sections = [ + _format_command_output(command, self.runtime_state.config_cli_help[command]) + for command in _AGENT_FILE_CLI_HELP_COMMANDS + if command in self.runtime_state.config_cli_help + ] + if not command_sections: + return "" + return ( + "Agent file CLI reference for installed `dify-agent`:\n" + + "\n\n".join(command_sections) + + f"\n\n{_AGENT_FILE_UPLOAD_REPLY_HINT}" + ) def _format_config_context_json(self) -> str: return self.config.model_dump_json(exclude=_CONFIG_CONTEXT_EXCLUDE, exclude_none=True) @@ -226,4 +261,8 @@ class DifyConfigLayer(PlainLayer[DifyConfigDeps, DifyConfigLayerConfig, DifyConf return "\n".join(lines) +def _format_command_output(command: str, output: str) -> str: + return f"Command:\n$ {command}\nOutput:\n{output}" + + __all__ = ["DifyConfigLayer", "DifyConfigLayerError"] diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py index fec1109e74d..8ac4b91c189 100644 --- a/dify-agent/src/dify_agent/layers/drive/layer.py +++ b/dify-agent/src/dify_agent/layers/drive/layer.py @@ -21,6 +21,7 @@ from typing_extensions import Self, override from agenton.layers import EmptyRuntimeState, LayerDeps, PlainLayer from dify_agent.agent_stub.protocol import agent_stub_drive_base_for_ref +from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig from dify_agent.layers.shell.layer import DifyShellLayer @@ -122,13 +123,17 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim def _format_agent_stub_cli_help(self) -> str: command_sections = [ - f"$ {command}\n{self._agent_stub_cli_help[command]}" + _format_command_output(command, self._agent_stub_cli_help[command]) for command in _AGENT_STUB_FILE_HELP_COMMANDS if command in self._agent_stub_cli_help ] if not command_sections: return "" - return "Agent Stub file CLI help:\n" + "\n\n".join(command_sections) + return ( + "Agent Stub file CLI reference for installed `dify-agent`:\n" + + "\n\n".join(command_sections) + + f"\n\n{_AGENT_FILE_UPLOAD_REPLY_HINT}" + ) async def _load_agent_stub_cli_help(self) -> None: self._agent_stub_cli_help = {} @@ -256,4 +261,8 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim return f"{skill_key.rsplit('/', 1)[0]}/" +def _format_command_output(command: str, output: str) -> str: + return f"Command:\n$ {command}\nOutput:\n{output}" + + __all__ = ["DifyDriveLayer", "DifyDriveLayerError"] diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 529e99034df..b2939aadadd 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -72,36 +72,38 @@ _SHELL_OUTPUT_PROMPT_EDGE_BYTES = 8 * 1024 _SHELLCTL_OUTPUT_LIMIT_BYTES = 2 * _SHELL_OUTPUT_PROMPT_EDGE_BYTES _REMOTE_COMPLETE_OUTPUT_MAX_BYTES = 1024 * 1024 _REMOTE_COMMAND_TIMEOUT_SECONDS = 60.0 -_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools: +_SHELL_LAYER_PREFIX_PROMPT = """You can run commands in an isolated shell workspace. + +Available shell tools: 1. shell_run - Start a new shell job in the current isolated workspace. - Use it to execute commands or scripts. + Starts a new shell job in the current workspace. + Use it to run commands or scripts. 2. shell_wait - Wait for more output or completion from an existing shell job. + Waits for more output or completion from an existing shell job. Use it when shell_run returns done=false. 3. shell_input - Send stdin text to a running shell job, then wait for new output. - Use it for interactive commands that are waiting for input. + Sends stdin text to a running shell job, then waits for new output. + Use it only when an interactive command is waiting for input. 4. shell_interrupt - Interrupt a running shell job. + Interrupts a running shell job. Use it to stop a long-running, stuck, or no-longer-needed command. Common arguments: - script: - The command or script to execute. Used by shell_run. + Command or script to execute. Used by shell_run. - job_id: - The id of a shell job returned by shell_run. + Shell job id returned by shell_run. Use it with shell_wait, shell_input, and shell_interrupt. Never invent a job_id. - timeout: - Maximum time, in seconds, to wait for output or completion for this tool call. + Maximum time in seconds to wait for output or completion for this tool call. A timeout does not necessarily mean the job has stopped; if done=false, use shell_wait again. - text: @@ -118,25 +120,33 @@ Usage rules: - Use shell_input only when the job is running and waiting for stdin. - Use shell_interrupt when a job is stuck or should be stopped. +Installed CLI: + +- `dify-agent` is already installed in this shell environment and can be used directly. +- Use the generated `dify-agent ... --help` output in the config prompt for exact command syntax. +- Do not install or recreate the `dify-agent` CLI. + Workspace persistence rules: -- The current workspace cwd is stable during this agent run, but it is temporary and may be deleted later. -- Do not use the current workspace cwd as persistent storage. -- $HOME outside the current workspace cwd is persistent storage. In build draft mode, when Agent config context reports - `config_version.kind` as `build_draft` and `config_version.writable` as true, changes there can be persisted for - later runs. In non-build-draft modes, those changes are rolled back. -- Saving config files, skills, env, or notes still requires the corresponding Agent config CLI mutation command; follow - the Agent config CLI help in the config layer. Shell file edits alone do not save config. +- The current workspace cwd is stable during this run, but it is temporary and may be deleted later. +- Do not treat files in the current workspace cwd as persisted state. +- In build mode, config changes persist only after you run the matching `dify-agent config ...` mutation command. +- Shell file edits alone do not save Agent config files, skills, env, or notes. +- In non-build modes, local shell changes are not a persistence mechanism for Agent configuration. -The script argument of shell_run can be a normal shell script, or a shebang script. -If the first line is a shebang, the shell layer executes the script directly. +shell_run script rules: + +- The script argument can be a normal shell script or a shebang script. +- If the first line is a shebang, the shell executes the script directly. Tips: - When using Python, prefer a uv script with a PEP 723 dependency header. +- If you need MCP, install the MCP server in the shell environment and start that server when you use it. - Example: +Example shell_run script: +[begin script] #!/usr/bin/env -S uv run --quiet --script # /// script # requires-python = ">=3.12" @@ -150,7 +160,8 @@ import httpx from rich import print response = httpx.get("https://example.com", timeout=10) -print(f"[green]status:[/green] {response.status_code}")""" +print(f"[green]status:[/green] {response.status_code}") +[end script]""" _SHELL_LAYER_SUFFIX_PROMPT = """Environment variables may contain API keys, tokens, or credentials. You may refer to environment variable names when needed.""" diff --git a/dify-agent/src/dify_agent/protocol/__init__.py b/dify-agent/src/dify_agent/protocol/__init__.py index a43b6cd4812..2fc5bd517ed 100644 --- a/dify-agent/src/dify_agent/protocol/__init__.py +++ b/dify-agent/src/dify_agent/protocol/__init__.py @@ -28,7 +28,6 @@ from .schemas import ( RunEventsResponse, RunFailedEvent, RunFailedEventData, - RunPurpose, RunLayerSpec, RunStartedEvent, RunStatus, @@ -78,7 +77,6 @@ __all__ = [ "RunEventsResponse", "RunFailedEvent", "RunFailedEventData", - "RunPurpose", "RunLayerSpec", "RunStartedEvent", "RunStatus", diff --git a/dify-agent/src/dify_agent/protocol/sandbox.py b/dify-agent/src/dify_agent/protocol/sandbox.py index 83f3d4b0adc..db375e6a7b5 100644 --- a/dify-agent/src/dify_agent/protocol/sandbox.py +++ b/dify-agent/src/dify_agent/protocol/sandbox.py @@ -112,6 +112,7 @@ class SandboxUploadedFile(BaseModel): transfer_method: Literal["tool_file"] = "tool_file" reference: str + download_url: str model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") diff --git a/dify-agent/src/dify_agent/protocol/schemas.py b/dify-agent/src/dify_agent/protocol/schemas.py index 1a02c7232a4..1cfafdc8123 100644 --- a/dify-agent/src/dify_agent/protocol/schemas.py +++ b/dify-agent/src/dify_agent/protocol/schemas.py @@ -24,10 +24,13 @@ whether each active layer is suspended or deleted when the run exits, with suspend as the default so successful terminal events can include resumable snapshots. Successful runs always publish the resumable Agenton session snapshot on the terminal ``run_succeeded`` event together with exactly one of the final -JSON-safe ``output`` or a deferred external ``deferred_tool_call`` payload. That -lets consumers treat terminal success events as complete run summaries without a -separate pause protocol. Session snapshots carry only layer lifecycle/runtime -state in compositor order; they do not persist output-layer config. Resumed +JSON-safe ``output`` or a deferred external ``deferred_tool_call`` payload. A +lifecycle-only run may also succeed with ``output = null`` and ``usage = null`` +when the composition intentionally omits the reserved model layer and only +replays layer enter/exit work from a supplied snapshot. That lets consumers +treat terminal success events as complete run summaries without a separate pause +protocol. Session snapshots carry only layer lifecycle/runtime state in +compositor order; they do not persist output-layer config. Resumed structured-output runs therefore must resubmit the same ``output`` layer in ``composition.layers[]`` so snapshot layer name/order still matches the composition and the runtime can rebuild the same structured output contract. @@ -50,7 +53,6 @@ DIFY_AGENT_MODEL_LAYER_ID: Final[str] = "llm" DIFY_AGENT_HISTORY_LAYER_ID: Final[str] = "history" DIFY_AGENT_OUTPUT_LAYER_ID: Final[str] = "output" RunStatus = Literal["running", "succeeded", "failed", "cancelled"] -RunPurpose = Literal["workflow_node", "single_step", "agent_app", "babysit", "fasten_preview"] RunEventType = Literal[ "run_started", "pydantic_ai_event", @@ -136,7 +138,6 @@ class CreateRunRequest(BaseModel): """ composition: RunComposition - purpose: RunPurpose = "workflow_node" idempotency_key: str | None = None metadata: dict[str, JsonValue] = Field(default_factory=dict) session_snapshot: CompositorSessionSnapshot | None = None @@ -334,10 +335,11 @@ class RunStartedEvent(BaseRunEvent): class PydanticAIStreamRunEvent(BaseRunEvent): - """Pydantic AI stream event using the upstream typed event model.""" + """Pydantic AI stream event with optional Dify Agent semantic annotations.""" type: Literal["pydantic_ai_event"] = "pydantic_ai_event" data: AgentStreamEvent + agent_message_delta: str | None = None class RunSucceededEvent(BaseRunEvent): @@ -402,7 +404,6 @@ __all__ = [ "RunEventsResponse", "RunFailedEvent", "RunFailedEventData", - "RunPurpose", "RunStartedEvent", "RunStatus", "RunStatusResponse", diff --git a/dify-agent/src/dify_agent/runtime/event_sink.py b/dify-agent/src/dify_agent/runtime/event_sink.py index 5dbe9fce62d..71df75b4916 100644 --- a/dify-agent/src/dify_agent/runtime/event_sink.py +++ b/dify-agent/src/dify_agent/runtime/event_sink.py @@ -89,11 +89,22 @@ async def emit_run_started(sink: RunEventSink, *, run_id: str) -> str: ) -async def emit_pydantic_ai_event(sink: RunEventSink, *, run_id: str, data: AgentStreamEvent) -> str: +async def emit_pydantic_ai_event( + sink: RunEventSink, + *, + run_id: str, + data: AgentStreamEvent, + agent_message_delta: str | None = None, +) -> str: """Emit one typed Pydantic AI stream event.""" return await emit_run_event( sink, - event=PydanticAIStreamRunEvent(run_id=run_id, data=data, created_at=utc_now()), + event=PydanticAIStreamRunEvent( + run_id=run_id, + data=data, + agent_message_delta=agent_message_delta, + created_at=utc_now(), + ), ) diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index 97fbfe4ba4c..2ad3aa99d8f 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -1,14 +1,21 @@ """Runtime execution for one scheduled Dify Agent run. The runner is storage-agnostic: it normalizes the public Dify composition into -Agenton's graph/config split, enters a fresh ``CompositorRun`` (or resumes one -from a snapshot), renders the current Dify system prompts into temporary -``message_history``, runs pydantic-ai with either the current ``run.user_prompts`` -or deferred external tool results, emits stream events, applies request-level -``on_exit`` signals, and then publishes a terminal success or failure event. The -Pydantic AI model is resolved from the active Agenton layer named by +Agenton's graph/config split and chooses one of two execution modes after the +composition is normalized and the ``on_exit`` policy is validated: + +- model runs: enter a fresh ``CompositorRun`` (or resume one from a snapshot), + render the current Dify system prompts into temporary ``message_history``, run + pydantic-ai with either the current ``run.user_prompts`` or deferred external + tool results, emit raw stream events with agent-message delta annotations, apply + request-level ``on_exit`` signals, and publish a terminal success or failure event; +- lifecycle-only runs: enter from a supplied snapshot, apply request-level + ``on_exit`` signals, exit without invoking a model, and succeed with explicit + ``output = null`` and ``usage = null``. + +The Pydantic AI model is resolved from the active Agenton layer named by ``DIFY_AGENT_MODEL_LAYER_ID``. An optional history layer contributes stored -message history only through session state; successful runs append only +message history only through session state; successful model runs append only ``result.new_messages()`` back into that layer so current system prompts are not persisted. An optional structured output layer named by ``DIFY_AGENT_OUTPUT_LAYER_ID`` is read after entry and resolved into an output @@ -31,11 +38,11 @@ from typing import Any, Literal, Protocol, cast, runtime_checkable import httpx from pydantic import JsonValue, TypeAdapter -from pydantic_ai.messages import AgentStreamEvent +from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta from pydantic_ai.output import OutputSpec from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults -from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput +from agenton.compositor import CompositorSessionSnapshot, LayerConfigInput, LayerProviderInput from agenton.layers.types import PydanticAITool from dify_agent.layers.ask_human.layer import get_ask_human_layer, validate_ask_human_layer_composition from dify_agent.layers.dify_core_tools.layer import DifyCoreToolsLayer @@ -97,6 +104,20 @@ class AgentRunValidationError(ValueError): """Raised when a run request is valid JSON but cannot execute.""" +def _has_model_layer(request: CreateRunRequest) -> bool: + """Return whether the public composition includes the reserved model layer.""" + return any(layer.name == DIFY_AGENT_MODEL_LAYER_ID for layer in request.composition.layers) + + +def _extract_agent_message_delta(event: AgentStreamEvent) -> str | None: + """Return agent-message text content from Pydantic AI stream events.""" + if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta): + return event.delta.content_delta + if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart): + return event.part.content + return None + + @dataclass(slots=True) class RunSuccessOutcome: """Normalized successful runner output before event emission.""" @@ -163,7 +184,7 @@ class AgentRunRunner: await self.sink.update_status(self.run_id, "succeeded") async def _run_agent(self) -> RunSuccessOutcome: - """Run pydantic-ai inside an entered Agenton run. + """Run the normalized request in model or lifecycle-only mode. Known request-shaped Agenton enter-time failures are normalized to ``AgentRunValidationError``. That includes the existing small class of @@ -190,6 +211,57 @@ class AgentRunRunner: except (KeyError, TypeError, ValueError) as exc: raise AgentRunValidationError(str(exc)) from exc + if not _has_model_layer(self.request): + return await self._run_lifecycle_only(compositor=compositor, layer_configs=layer_configs) + return await self._run_model(compositor=compositor, layer_configs=layer_configs) + + async def _run_lifecycle_only( + self, + *, + compositor: Any, + layer_configs: dict[str, LayerConfigInput], + ) -> RunSuccessOutcome: + """Replay only layer lifecycle work for a no-LLM composition plus snapshot.""" + if self.request.session_snapshot is None: + raise AgentRunValidationError( + f"Missing '{DIFY_AGENT_MODEL_LAYER_ID}' requires a session_snapshot for lifecycle-only runs." + ) + if self.request.deferred_tool_results is not None: + raise AgentRunValidationError( + f"Deferred tool results require the reserved '{DIFY_AGENT_MODEL_LAYER_ID}' layer." + ) + + entered_run = False + try: + async with compositor.enter(configs=layer_configs, session_snapshot=self.request.session_snapshot) as run: + entered_run = True + apply_layer_exit_signals(run, self.request.on_exit) + except RuntimeError as exc: + if not entered_run and is_agenton_enter_validation_runtime_error(exc): + raise AgentRunValidationError(str(exc)) from exc + raise + except ValueError as exc: + if not entered_run: + raise AgentRunValidationError(str(exc)) from exc + raise + + if run.session_snapshot is None: + raise RuntimeError("Agenton run did not produce a session snapshot after exit.") + return RunSuccessOutcome( + result_kind="output", + output=None, + deferred_tool_call=None, + session_snapshot=run.session_snapshot, + usage=None, + ) + + async def _run_model( + self, + *, + compositor: Any, + layer_configs: dict[str, LayerConfigInput], + ) -> RunSuccessOutcome: + """Run the normal model/deferred-tool path inside an entered Agenton run.""" entered_run = False output: JsonValue | None = None deferred_tool_call: DeferredToolCallPayload | None = None @@ -206,7 +278,13 @@ class AgentRunRunner: async def handle_events(_ctx: object, events: AsyncIterable[AgentStreamEvent]) -> None: async for event in events: - _ = await emit_pydantic_ai_event(self.sink, run_id=self.run_id, data=event) + text_delta = _extract_agent_message_delta(event) + _ = await emit_pydantic_ai_event( + self.sink, + run_id=self.run_id, + data=event, + agent_message_delta=text_delta, + ) try: output_contract = resolve_run_output_contract(run) diff --git a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py index 1f952669a08..69d71f865cf 100644 --- a/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py +++ b/dify-agent/tests/local/dify_agent/adapters/llm/test_model.py @@ -242,6 +242,45 @@ class DifyLLMAdapterModelTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.parts[0].part_kind, "text") self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + async def test_request_uses_unique_fallback_ids_for_same_name_tool_calls(self) -> None: + messages = [ + ModelRequest(parts=[UserPromptPart("hello")]), + ModelResponse( + parts=[ + ToolCallPart(tool_name="lookup", args={"query": "first"}, tool_call_id=""), + ToolCallPart(tool_name="lookup", args={"query": "second"}, tool_call_id=""), + ] + ), + ] + + def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content.decode("utf-8")) + prompt_messages = payload["data"]["prompt_messages"] + tool_calls = prompt_messages[1]["tool_calls"] + + self.assertEqual(tool_calls[0]["id"], "tool-call-0-lookup") + self.assertEqual(tool_calls[1]["id"], "tool-call-1-lookup") + + return build_stream_response(*single_text_chunk("adapter response", prompt_tokens=11, completion_tokens=7)) + + async with self.mock_daemon_stream(httpx.MockTransport(handler)): + adapter = DifyLLMAdapterModel( + "demo-model", + self.make_provider(), + model_provider="openai", + credentials={"api_key": "secret"}, + ) + + response = await adapter.request( + messages, + model_settings=None, + model_request_parameters=ModelRequestParameters(), + ) + + self.assertEqual(response.model_name, "demo-model") + self.assertEqual(response.parts[0].part_kind, "text") + self.assertEqual(cast(TextPart, response.parts[0]).content, "adapter response") + async def test_request_collapses_text_only_assistant_history_parts_to_string_content(self) -> None: messages = [ ModelRequest(parts=[UserPromptPart("initial request")]), diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py index 61ffcec9e3a..5dbf0bafe5a 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_drive.py @@ -1,5 +1,7 @@ from __future__ import annotations +import base64 +import json from io import BytesIO from pathlib import Path import stat @@ -14,16 +16,22 @@ from dify_agent.agent_stub.cli._drive import ( pull_drive_from_environment, push_drive_from_environment, ) -from dify_agent.agent_stub.cli._files import UploadedToolFileMapping, UploadedToolFileResource +from dify_agent.agent_stub.cli._files import UploadedToolFileResource from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubDriveCommitRequest, AgentStubDriveCommitResponse, AgentStubDriveItem, + AgentStubFileMapping, AgentStubDriveManifestResponse, ) +def _reference(record_id: str) -> str: + payload = base64.urlsafe_b64encode(json.dumps({"record_id": record_id}, separators=(",", ":")).encode()).decode() + return f"dify-file-ref:{payload}" + + def test_list_drive_manifest_from_environment_returns_manifest_model(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") @@ -535,7 +543,7 @@ def test_push_drive_from_environment_commits_single_file(monkeypatch: pytest.Mon monkeypatch.setattr( "dify_agent.agent_stub.cli._drive.upload_tool_file_resource_from_environment", lambda *, path: UploadedToolFileResource( - mapping=UploadedToolFileMapping(reference="dify-file-ref:tool-file-1"), + mapping=AgentStubFileMapping(transfer_method="tool_file", reference=_reference("tool-file-1")), tool_file_id="tool-file-1", ), ) @@ -640,7 +648,10 @@ def test_push_drive_from_environment_kind_skill_standardizes_skill_directory( def fake_upload(*, path: str) -> UploadedToolFileResource: uploaded_paths.append(Path(path).name) return UploadedToolFileResource( - mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"), + mapping=AgentStubFileMapping( + transfer_method="tool_file", + reference=_reference(Path(path).name), + ), tool_file_id=Path(path).name, ) @@ -697,7 +708,10 @@ def test_push_drive_from_environment_kind_skill_archive_excludes_transient_entri with ZipFile(path) as archive: archive_entries.extend(sorted(archive.namelist())) return UploadedToolFileResource( - mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"), + mapping=AgentStubFileMapping( + transfer_method="tool_file", + reference=_reference(Path(path).name), + ), tool_file_id=Path(path).name, ) @@ -821,7 +835,10 @@ def test_push_drive_from_environment_kind_dir_keeps_user_files_that_skill_packag def fake_upload(*, path: str) -> UploadedToolFileResource: uploaded_paths.append(Path(path).relative_to(root).as_posix()) return UploadedToolFileResource( - mapping=UploadedToolFileMapping(reference=f"dify-file-ref:{Path(path).name}"), + mapping=AgentStubFileMapping( + transfer_method="tool_file", + reference=_reference(Path(path).name), + ), tool_file_id=Path(path).name, ) diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py index c2f2d44afb4..6a65daccb9a 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_files.py @@ -12,6 +12,7 @@ from dify_agent.agent_stub.cli._files import ( upload_tool_file_resource_from_environment, ) from dify_agent.agent_stub.client._errors import AgentStubTransferError, AgentStubValidationError +from dify_agent.agent_stub.protocol.agent_stub import AgentStubFileMapping def _reference(record_id: str) -> str: @@ -48,18 +49,42 @@ def test_upload_file_from_environment_requests_signed_url_and_normalizes_output( "dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync", fake_upload_file_to_signed_url_sync, ) + captured_download_request: dict[str, object] = {} + + def fake_request_agent_stub_file_download_sync(**kwargs): + captured_download_request["file"] = kwargs["file"] + return type( + "Response", + (), + { + "filename": "report.pdf", + "mime_type": "application/pdf", + "size": 12, + "download_url": "https://files.example.com/download", + }, + )() + + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync", + fake_request_agent_stub_file_download_sync, + ) result = upload_file_from_environment(path=str(source)) assert result.model_dump() == { "transfer_method": "tool_file", "reference": _reference("tool-file-1"), + "download_url": "https://files.example.com/download", } assert captured == { "filename": "report.pdf", "mimetype": "application/pdf", "file_bytes": b"report-bytes", } + assert captured_download_request["file"] == AgentStubFileMapping( + transfer_method="tool_file", + reference=_reference("tool-file-1"), + ) def test_upload_tool_file_resource_from_environment_preserves_tool_file_id( @@ -79,12 +104,17 @@ def test_upload_tool_file_resource_from_environment_preserves_tool_file_id( "dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync", lambda **_kwargs: {"id": "tool-file-1", "reference": _reference("tool-file-1")}, ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync", + lambda **_kwargs: pytest.fail("resource helper must not request download_url"), + ) result = upload_tool_file_resource_from_environment(path=str(source)) assert result.mapping.model_dump() == { "transfer_method": "tool_file", "reference": _reference("tool-file-1"), + "url": None, } assert result.tool_file_id == "tool-file-1" @@ -187,6 +217,34 @@ def test_upload_file_from_environment_rejects_non_canonical_reference( _ = upload_file_from_environment(path=str(source)) +def test_upload_file_from_environment_rejects_missing_download_url( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "report.pdf" + source.write_bytes(b"report-bytes") + monkeypatch.setenv("DIFY_AGENT_STUB_API_BASE_URL", "https://agent.example.com/agent-stub") + monkeypatch.setenv("DIFY_AGENT_STUB_AUTH_JWE", "test-jwe") + + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.request_agent_stub_file_upload_sync", + lambda **_kwargs: type("Response", (), {"upload_url": "https://files.example.com/upload"})(), + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.upload_file_to_signed_url_sync", + lambda **_kwargs: {"id": "tool-file-1", "reference": _reference("tool-file-1")}, + ) + monkeypatch.setattr( + "dify_agent.agent_stub.cli._files.request_agent_stub_file_download_sync", + lambda **_kwargs: type( + "Response", (), {"filename": "report.pdf", "mime_type": "application/pdf", "size": 12} + )(), + ) + + with pytest.raises(AgentStubTransferError, match="missing download_url"): + _ = upload_file_from_environment(path=str(source)) + + def test_download_file_from_environment_supports_mapping_json( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py index 1c27fee25af..3018629e105 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/cli/test_main.py @@ -9,6 +9,7 @@ import pytest from dify_agent.agent_stub.cli._drive import DrivePullResult from dify_agent.agent_stub.cli.main import main +from dify_agent.agent_stub.client._errors import AgentStubTransferError from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConfigFileItemsResponse, AgentStubConfigFileItem, @@ -450,6 +451,7 @@ def test_cli_file_upload_prints_uploaded_tool_file_json( { "transfer_method": "tool_file", "reference": _reference(Path(path).name), + "download_url": f"https://files.example.com/{Path(path).name}", } ) }, @@ -464,9 +466,31 @@ def test_cli_file_upload_prints_uploaded_tool_file_json( assert json.loads(captured.out) == { "transfer_method": "tool_file", "reference": _reference("report.pdf"), + "download_url": "https://files.example.com/report.pdf", } +def test_cli_file_upload_exits_non_zero_without_partial_json_when_download_lookup_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _patch_cli_module( + monkeypatch, + "_files_module", + upload_file_from_environment=lambda *, path: (_ for _ in ()).throw( + AgentStubTransferError("signed file download request failed") + ), + ) + + with pytest.raises(SystemExit) as exc_info: + main(["file", "upload", "/tmp/report.pdf"]) + + captured = capsys.readouterr() + assert exc_info.value.code == 1 + assert captured.out == "" + assert "signed file download request failed" in captured.err + + def test_cli_file_download_prints_saved_path( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -485,14 +509,14 @@ def test_cli_file_download_prints_saved_path( assert captured.out.strip() == "/tmp/report.pdf" -def test_cli_file_download_supports_mapping_json( +def test_cli_file_download_rejects_mapping_option( monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], ) -> None: - captured_kwargs: dict[str, object] = {} + called = False - def fake_download_file_from_environment(**kwargs): - captured_kwargs.update(kwargs) + def fake_download_file_from_environment(**_kwargs): + nonlocal called + called = True return type("Response", (), {"path": Path("/tmp/inputs/report.pdf")})() _patch_cli_module( @@ -513,15 +537,8 @@ def test_cli_file_download_supports_mapping_json( ] ) - captured = capsys.readouterr() - assert exc_info.value.code == 0 - assert captured_kwargs == { - "transfer_method": None, - "reference_or_url": None, - "mapping": json.dumps({"transfer_method": "tool_file", "reference": _reference("tool-file-1")}), - "local_dir": "/tmp/inputs", - } - assert captured.out.strip() == "/tmp/inputs/report.pdf" + assert exc_info.value.code == 2 + assert called is False def test_cli_file_download_rejects_legacy_positional_directory( diff --git a/dify-agent/tests/local/dify_agent/client/test_client.py b/dify-agent/tests/local/dify_agent/client/test_client.py index 3a1525f015c..9f15ea9d2e6 100644 --- a/dify-agent/tests/local/dify_agent/client/test_client.py +++ b/dify-agent/tests/local/dify_agent/client/test_client.py @@ -259,7 +259,11 @@ def test_sync_sandbox_methods_post_dtos_and_parse_responses() -> None: 200, json={ "path": "report.txt", - "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + "file": { + "transfer_method": "tool_file", + "reference": "dify-file-ref:file-1", + "download_url": "https://files.example.com/report.txt", + }, }, ) raise AssertionError(f"unexpected request: {request.method} {request.url}") @@ -276,6 +280,7 @@ def test_sync_sandbox_methods_post_dtos_and_parse_responses() -> None: assert preview.text == "hello" assert isinstance(uploaded, SandboxUploadResponse) assert uploaded.file.reference == "dify-file-ref:file-1" + assert uploaded.file.download_url == "https://files.example.com/report.txt" def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None: @@ -293,7 +298,11 @@ def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None: 200, json={ "path": "report.txt", - "file": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}, + "file": { + "transfer_method": "tool_file", + "reference": "dify-file-ref:file-1", + "download_url": "https://files.example.com/report.txt", + }, }, ) raise AssertionError(f"unexpected request: {request.method} {request.url}") @@ -309,11 +318,35 @@ def test_async_sandbox_methods_post_dtos_and_parse_responses() -> None: assert listing.path == "." assert preview.text == "hello" assert uploaded.file.reference == "dify-file-ref:file-1" + assert uploaded.file.download_url == "https://files.example.com/report.txt" await http_client.aclose() asyncio.run(scenario()) +def test_sync_upload_sandbox_file_rejects_missing_download_url() -> None: + locator = _sandbox_locator() + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path != "/sandbox/files/upload": + raise AssertionError(f"unexpected request: {request.method} {request.url}") + return httpx.Response( + 200, + json={ + "path": "report.txt", + "file": { + "transfer_method": "tool_file", + "reference": "dify-file-ref:file-1", + }, + }, + ) + + client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler))) + + with pytest.raises(DifyAgentValidationError): + _ = client.upload_sandbox_file_sync(locator, "report.txt") + + def test_sync_sandbox_methods_map_invalid_json_to_validation_error() -> None: responses = iter([httpx.Response(200, text="not-json"), httpx.Response(404, json={"detail": "missing"})]) diff --git a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py index 0ea4bf1195f..f085f6cc51b 100644 --- a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py @@ -9,7 +9,11 @@ import pytest from dify_agent.adapters.shell.shellctl import ShellctlProvider from dify_agent.layers.config import DifyConfigLayerConfig -from dify_agent.layers.config.layer import DifyConfigLayer, DifyConfigLayerError +from dify_agent.layers.config.layer import ( + DifyConfigLayer, + DifyConfigLayerError, + _AGENT_FILE_UPLOAD_REPLY_HINT, +) from dify_agent.layers.shell import DifyShellLayerConfig from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer @@ -127,7 +131,19 @@ async def test_on_context_create_computes_runtime_fields_and_pulls_mentioned_ass assert layer.runtime_state.pulled_skill_outputs == {"alpha": "/workspace/.dify_conf/skills/alpha\n# Alpha\nUse it."} assert layer.runtime_state.pulled_file_outputs == {"guide.txt": "/workspace/.dify_conf/files/guide.txt"} assert "dify-agent config note push --help" in layer.runtime_state.config_cli_help + assert "dify-agent file upload --help" in layer.runtime_state.config_cli_help + assert "dify-agent file download --help" in layer.runtime_state.config_cli_help assert layer.runtime_state.push_spec_json_schema == "" + suffix_prompt = layer.build_suffix_prompt() + assert suffix_prompt.index("Agent config CLI reference for installed `dify-agent`:") < suffix_prompt.index( + "Agent file CLI reference for installed `dify-agent`:" + ) + assert "$ dify-agent file upload --help" in suffix_prompt + assert "$ dify-agent file download --help" in suffix_prompt + assert suffix_prompt.index("$ dify-agent file upload --help") < suffix_prompt.index( + "$ dify-agent file download --help" + ) + assert _AGENT_FILE_UPLOAD_REPLY_HINT in suffix_prompt @pytest.mark.anyio diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py b/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py index 86b04f95297..afcef5cc396 100644 --- a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py @@ -8,7 +8,7 @@ import pytest from dify_agent.adapters.shell.shellctl import ShellctlProvider from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig -from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError +from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError, _AGENT_FILE_UPLOAD_REPLY_HINT from dify_agent.layers.shell import DifyShellLayerConfig from dify_agent.layers.shell.layer import CompleteRemoteCommandResult, DifyShellLayer @@ -112,14 +112,18 @@ def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt() -> None: layer._agent_stub_cli_help = { "dify-agent file --help": _file_help_output("dify-agent file --help"), "dify-agent file upload --help": _file_help_output("dify-agent file upload --help"), + "dify-agent file download --help": _file_help_output("dify-agent file download --help"), } assert len(layer.suffix_prompts) == 1 prompt = layer.suffix_prompts[0] assert "Other available skills" in prompt assert "other-skill: Other Skill" in prompt - assert "Agent Stub file CLI help" in prompt + assert "Agent Stub file CLI reference for installed `dify-agent`" in prompt assert "$ dify-agent file upload --help" in prompt + assert "$ dify-agent file download --help" in prompt + assert prompt.index("$ dify-agent file upload --help") < prompt.index("$ dify-agent file download --help") + assert _AGENT_FILE_UPLOAD_REPLY_HINT in prompt assert "dify-agent drive" not in prompt diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py index 75fa8dda204..be5ac5be9a5 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -42,7 +42,11 @@ from dify_agent.layers.dify_plugin.configs import ( def test_run_event_adapter_round_trips_typed_variants() -> None: events = [ RunStartedEvent(run_id="run-1"), - PydanticAIStreamRunEvent(run_id="run-1", data=FinalResultEvent(tool_name=None, tool_call_id=None)), + PydanticAIStreamRunEvent( + run_id="run-1", + data=FinalResultEvent(tool_name=None, tool_call_id=None), + agent_message_delta="hello", + ), RunSucceededEvent( run_id="run-1", data=RunSucceededEventData( @@ -101,6 +105,7 @@ def test_create_run_request_rejects_old_compositor_payload_and_model_layer_id_is def test_protocol_package_no_longer_exports_execution_context_dto() -> None: assert not hasattr(protocol_exports, "ExecutionContext") + assert not hasattr(protocol_exports, "RunPurpose") def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_graph_config() -> None: @@ -131,7 +136,6 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ } ) request = CreateRunRequest( - purpose="workflow_node", idempotency_key="workflow-run-1:node-execution-1", metadata={"source": "unit_test"}, composition=RunComposition( @@ -177,7 +181,6 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_ "invoke_from": "service-api", "trace_id": "trace-1", } - assert payload["purpose"] == "workflow_node" assert payload["idempotency_key"] == "workflow-run-1:node-execution-1" assert payload["metadata"] == {"source": "unit_test"} assert payload["composition"]["layers"][0]["config"] == {"prefix": "system", "user": "hello", "suffix": []} @@ -456,6 +459,16 @@ def test_create_run_request_rejects_removed_top_level_execution_context() -> Non ) +def test_create_run_request_rejects_removed_top_level_purpose() -> None: + with pytest.raises(ValidationError): + _ = CreateRunRequest.model_validate( + { + "composition": {"layers": []}, + "purpose": "session_cleanup", + } + ) + + def test_layer_exit_signals_reject_extra_fields() -> None: with pytest.raises(ValidationError): _ = LayerExitSignals.model_validate({"default": "suspend", "unknown": "value"}) diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index 6c05cb65703..87e12854496 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -56,6 +56,7 @@ from dify_agent.protocol.schemas import ( CreateRunRequest, DeferredToolResultsPayload, LayerExitSignals, + PydanticAIStreamRunEvent, RunComposition, RunLayerSpec, RunSucceededEvent, @@ -196,6 +197,44 @@ def _request( ) +def _lifecycle_only_request( + *, + on_exit: LayerExitSignals | None = None, + session_snapshot: CompositorSessionSnapshot | None = None, + deferred_tool_results: DeferredToolResultsPayload | None = None, +) -> CreateRunRequest: + snapshot = session_snapshot or CompositorSessionSnapshot( + layers=[ + LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + LayerSessionSnapshot(name="execution_context", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}), + ] + ) + return CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), + ), + ] + ), + session_snapshot=snapshot, + deferred_tool_results=deferred_tool_results, + on_exit=on_exit or LayerExitSignals(default=ExitIntent.DELETE), + ) + + def _recursive_output_schema() -> dict[str, object]: return { "type": "object", @@ -381,6 +420,8 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa assert "agent_output" not in event_types assert "session_snapshot" not in event_types assert event_types[-1:] == ["run_succeeded"] + pydantic_events = [event for event in sink.events["run-1"] if isinstance(event, PydanticAIStreamRunEvent)] + assert "".join(event.agent_message_delta or "" for event in pydantic_events) == "done" terminal = sink.events["run-1"][-1] assert isinstance(terminal, RunSucceededEvent) assert terminal.data.output == "done" @@ -902,9 +943,15 @@ def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.Mo assert http_client.is_closed is False return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] - async def fake_get_tools(self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + async def fake_get_tools( + self: DifyPluginToolsLayer, + *, + http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, + ) -> list[Tool[object]]: assert self.config.tools[0].tool_name == "web_search" assert http_client.is_closed is False + assert dify_api_http_client.is_closed is False return [Tool(plugin_tool, name="web_search")] class FakeResult: @@ -1218,8 +1265,14 @@ def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers( assert http_client.is_closed is False return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] - async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + async def fake_get_tools( + _self: DifyPluginToolsLayer, + *, + http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, + ) -> list[Tool[object]]: assert http_client.is_closed is False + assert dify_api_http_client.is_closed is False return [Tool(duplicate_tool, name="shared_tool")] def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object: @@ -1336,8 +1389,14 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools( assert http_client.is_closed is False return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] - async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + async def fake_get_tools( + _self: DifyPluginToolsLayer, + *, + http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, + ) -> list[Tool[object]]: assert http_client.is_closed is False + assert dify_api_http_client.is_closed is False return [Tool(dynamic_duplicate_tool, name="web_search")] def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object: @@ -1440,8 +1499,14 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( assert http_client.is_closed is False return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType] - async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]: + async def fake_get_tools( + _self: DifyPluginToolsLayer, + *, + http_client: httpx.AsyncClient, + dify_api_http_client: httpx.AsyncClient, + ) -> list[Tool[object]]: assert http_client.is_closed is False + assert dify_api_http_client.is_closed is False async def duplicate_shell_run() -> str: return "tool" @@ -1483,17 +1548,23 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers( type="plain.prompt", config=PromptLayerConfig(prefix="system", user="hello"), ), - RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()), RunLayerSpec( name="execution_context", type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, config=DifyExecutionContextLayerConfig( tenant_id="tenant-1", + agent_id="agent-1", user_from="account", agent_mode="workflow_run", invoke_from="service-api", ), ), + RunLayerSpec( + name="shell", + type=DIFY_SHELL_LAYER_TYPE_ID, + deps={"execution_context": "execution_context"}, + config=DifyShellLayerConfig(), + ), RunLayerSpec( name=DIFY_AGENT_MODEL_LAYER_ID, type="dify.plugin.llm", @@ -1760,6 +1831,112 @@ def test_runner_applies_on_exit_overrides_to_success_snapshot(monkeypatch: pytes } +def test_runner_lifecycle_only_cleanup_succeeds_without_model_and_emits_no_pydantic_ai_events() -> None: + request = _lifecycle_only_request() + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-lifecycle-only", + plugin_daemon_http_client=client, + dify_api_http_client=client, + ).run() + + asyncio.run(scenario()) + + events = sink.events["run-lifecycle-only"] + assert [event.type for event in events] == ["run_started", "run_succeeded"] + terminal = events[-1] + assert isinstance(terminal, RunSucceededEvent) + assert terminal.data.output is None + assert terminal.data.usage is None + assert {layer.name: layer.lifecycle_state for layer in terminal.data.session_snapshot.layers} == { + "prompt": LifecycleState.CLOSED, + "execution_context": LifecycleState.CLOSED, + } + + +def test_runner_lifecycle_only_requires_session_snapshot() -> None: + request = _request(llm_layer_name="not-llm") + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="session_snapshot"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-lifecycle-only-missing-snapshot", + plugin_daemon_http_client=client, + dify_api_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-lifecycle-only-missing-snapshot"]] == ["run_started", "run_failed"] + assert sink.statuses["run-lifecycle-only-missing-snapshot"] == "failed" + + +def test_runner_lifecycle_only_rejects_deferred_tool_results() -> None: + request = _lifecycle_only_request( + deferred_tool_results=DeferredToolResultsPayload.model_validate({"calls": {"tool-call-1": {"ok": True}}}) + ) + sink = InMemoryRunEventSink() + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(AgentRunValidationError, match="Deferred tool results"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-lifecycle-only-deferred-results", + plugin_daemon_http_client=client, + dify_api_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-lifecycle-only-deferred-results"]] == [ + "run_started", + "run_failed", + ] + assert sink.statuses["run-lifecycle-only-deferred-results"] == "failed" + + +def test_runner_lifecycle_only_exit_hook_failure_emits_run_failed_not_validation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + request = _lifecycle_only_request() + sink = InMemoryRunEventSink() + + def _explode(_run: object, _signals: LayerExitSignals) -> None: + raise RuntimeError("delete hook failed") + + monkeypatch.setattr("dify_agent.runtime.runner.apply_layer_exit_signals", _explode) + + async def scenario() -> None: + async with httpx.AsyncClient() as client: + with pytest.raises(RuntimeError, match="delete hook failed"): + await AgentRunRunner( + sink=sink, + request=request, + run_id="run-lifecycle-only-exit-hook-failure", + plugin_daemon_http_client=client, + dify_api_http_client=client, + ).run() + + asyncio.run(scenario()) + + assert [event.type for event in sink.events["run-lifecycle-only-exit-hook-failure"]] == [ + "run_started", + "run_failed", + ] + assert sink.statuses["run-lifecycle-only-exit-hook-failure"] == "failed" + + def test_runner_passes_output_layer_spec_to_agent_and_serializes_structured_result( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2363,13 +2540,39 @@ def test_runner_fails_blank_string_user_prompt_list() -> None: assert sink.statuses["run-3"] == "failed" -def test_runner_requires_llm_layer_id() -> None: - request = _request(llm_layer_name="not-llm") +def test_runner_rejects_reserved_llm_layer_name_with_wrong_type() -> None: + request = CreateRunRequest( + composition=RunComposition( + layers=[ + RunLayerSpec( + name="prompt", + type="plain.prompt", + config=PromptLayerConfig(prefix="system", user="hello"), + ), + RunLayerSpec( + name="execution_context", + type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, + config=DifyExecutionContextLayerConfig( + tenant_id="tenant-1", + user_from="account", + agent_mode="workflow_run", + invoke_from="service-api", + ), + ), + RunLayerSpec( + name=DIFY_AGENT_MODEL_LAYER_ID, + type="plain.prompt", + config=PromptLayerConfig(user="not an llm"), + ), + ] + ), + on_exit=LayerExitSignals(), + ) sink = InMemoryRunEventSink() async def scenario() -> None: async with httpx.AsyncClient() as client: - with pytest.raises(AgentRunValidationError, match="llm"): + with pytest.raises(AgentRunValidationError, match="DifyPluginLLMLayer"): await AgentRunRunner( sink=sink, request=request, diff --git a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py index 520aae02248..6bbe954ad7f 100644 --- a/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py +++ b/dify-agent/tests/local/dify_agent/server/test_sandbox_files.py @@ -394,7 +394,7 @@ def test_embedded_scripts_allow_parent_relative_paths(tmp_path: Path) -> None: "import sys", 'if sys.argv[1:] != ["file", "upload", "../shared/notes.txt"]:', ' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")', - 'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))', + 'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref", "download_url": "https://files.example.com/notes.txt"}))', ] ) + "\n", @@ -424,7 +424,11 @@ def test_embedded_scripts_allow_parent_relative_paths(tmp_path: Path) -> None: } assert upload_payload == { "path": "../shared/notes.txt", - "file": {"transfer_method": "tool_file", "reference": "file-ref"}, + "file": { + "transfer_method": "tool_file", + "reference": "file-ref", + "download_url": "https://files.example.com/notes.txt", + }, } @@ -448,7 +452,7 @@ def test_embedded_scripts_expand_home_relative_paths(tmp_path: Path) -> None: "import sys", 'if sys.argv[1:] != ["file", "upload", "~/shared/notes.txt"]:', ' raise SystemExit(f"unexpected args: {sys.argv[1:]!r}")', - 'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref"}))', + 'print(json.dumps({"transfer_method": "tool_file", "reference": "file-ref", "download_url": "https://files.example.com/notes.txt"}))', ] ) + "\n", @@ -474,7 +478,11 @@ def test_embedded_scripts_expand_home_relative_paths(tmp_path: Path) -> None: } assert upload_payload == { "path": "~/shared/notes.txt", - "file": {"transfer_method": "tool_file", "reference": "file-ref"}, + "file": { + "transfer_method": "tool_file", + "reference": "file-ref", + "download_url": "https://files.example.com/notes.txt", + }, } @@ -508,7 +516,11 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None: output=_wrap( { "path": "report.txt", - "file": {"transfer_method": "tool_file", "reference": "file-ref"}, + "file": { + "transfer_method": "tool_file", + "reference": "file-ref", + "download_url": "https://files.example.com/report.txt", + }, }, noise=True, ), @@ -519,6 +531,7 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None: assert result.file.transfer_method == "tool_file" assert result.file.reference == "file-ref" + assert result.file.download_url == "https://files.example.com/report.txt" script_call = _sandbox_python_run_call(client) assert script_call.cwd == "/home/agent-1/workspace/abc12ff" assert script_call.env == { @@ -529,6 +542,26 @@ def test_upload_injects_agent_stub_env_and_returns_mapping() -> None: } +def test_upload_rejects_missing_download_url_in_shell_payload() -> None: + service, _client = _service( + lambda script, cwd, env, timeout: _Job( + job_id="sandbox-job", + output=_wrap( + { + "path": "report.txt", + "file": { + "transfer_method": "tool_file", + "reference": "file-ref", + }, + } + ), + ) + ) + + with pytest.raises(SandboxFileError, match="sandbox command returned invalid payload"): + _ = asyncio.run(service.upload_file(SandboxUploadRequest(locator=_locator(), path="report.txt"))) + + def test_shell_result_details_include_output_metadata_and_tail() -> None: details = _shell_result_details( _complete_result(output="hello", output_complete=False, incomplete_reason="output_limit") @@ -572,7 +605,14 @@ def test_read_and_upload_allow_relative_paths( output=_wrap( {"path": expected_path, "size": 5, "truncated": False, "binary": False, "text": "hello"} if isinstance(sandbox_request, SandboxReadRequest) - else {"path": expected_path, "file": {"transfer_method": "tool_file", "reference": "file-ref"}} + else { + "path": expected_path, + "file": { + "transfer_method": "tool_file", + "reference": "file-ref", + "download_url": "https://files.example.com/report.txt", + }, + } ), ) ) @@ -583,5 +623,6 @@ def test_read_and_upload_allow_relative_paths( else: result = asyncio.run(service.upload_file(sandbox_request)) assert result.path == expected_path + assert result.file.download_url == "https://files.example.com/report.txt" assert expected_command in _sandbox_python_run_call(client).script diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index f432eff7d77..33b74103ee7 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -28,7 +28,7 @@ Use tags in three layers: - `@build` — Build mode and Build draft behavior. - `@build-unavailable-resources` — feature-gated Build chat recovery when the user requests unavailable Skills or Tools. - `@files` — Files section upload, display, and fixture behavior. -- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format, size, count, and in-progress upload recovery remain feature-gated until their product contracts are stable. +- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format and size rejection remain feature-gated until their product contracts are stable. - `@knowledge` — Knowledge Retrieval configuration display, persistence, and reference cleanup. - `@advanced-settings` — Env Editor, Content Moderation, and related Advanced Settings behavior. - `@agent-create` — Agent Roster creation and initial Configure navigation. @@ -225,6 +225,6 @@ Order blocked steps by the real owner of the first unresolved condition. If a sc Use partial coverage only when current product behavior is intentionally narrower than the written requirement and the test still asserts a real user-visible behavior. Example: Files are currently flat in Agent config files, so the flat Files list can be asserted while tree display remains blocked until product support exists. -Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format, size, count, and in-progress upload limit cases remain feature-gated until the product exposes stable Agent config file restrictions and user-visible recovery/error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration. +Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format and size rejection remain feature-gated until the product exposes stable Agent config file restrictions and user-visible error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration. Do not mark a scenario as complete if it only proves setup state and does not assert the user-visible behavior or persisted product contract required by the case. diff --git a/e2e/features/agent-v2/files.feature b/e2e/features/agent-v2/files.feature index 700f90d2d30..233e57dc69e 100644 --- a/e2e/features/agent-v2/files.feature +++ b/e2e/features/agent-v2/files.feature @@ -60,19 +60,3 @@ Feature: Agent v2 files And I drop multiple Agent v2 files into the Files upload dialog Then the Agent v2 Files upload dialog should reject the multiple-file drop And I should not see the dropped Agent v2 files in the Files section - - @files-limits @feature-gated - Scenario: Agent v2 total file count limits are enforced - Given I am signed in as the default E2E admin - And Agent v2 total file count limits are available - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - Then Agent v2 total file count limits should be available - - @files-limits @feature-gated - Scenario: Leaving during Agent v2 file upload keeps a recoverable state - Given I am signed in as the default E2E admin - And Agent v2 in-progress file upload recovery is available - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - Then Agent v2 in-progress file upload recovery should be available diff --git a/e2e/features/agent-v2/output-variables.feature b/e2e/features/agent-v2/output-variables.feature index 0877d1ea0ae..9410ff656ff 100644 --- a/e2e/features/agent-v2/output-variables.feature +++ b/e2e/features/agent-v2/output-variables.feature @@ -1,13 +1,5 @@ @agent-v2 @authenticated @output-variables Feature: Agent v2 output variables - @standalone-output-variables @feature-gated - Scenario: Standalone Agent configure exposes Output Variables - Given I am signed in as the default E2E admin - And Agent v2 standalone Output Variables are available - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - Then Agent v2 standalone Output Variables should be available - @core @stable-model Scenario: Workflow Agent v2 output variables persist after refresh Given I am signed in as the default E2E admin @@ -63,23 +55,3 @@ Feature: Agent v2 output variables And I open the Agent v2 workflow node panel And I insert a file output reference from the Agent v2 workflow node task editor Then Agent v2 workflow task output reference deletion consistency should be available - - @output-retry-strategy @feature-gated @stable-model - Scenario: Workflow Agent v2 output retry strategy can be saved after refresh - Given I am signed in as the default E2E admin - And Agent v2 workflow output retry strategy is available - And the Agent Builder stable chat model is available - And a workflow app with an Agent v2 node has been created via API - When I open the app from the app list - And I open the Agent v2 workflow node panel - Then Agent v2 workflow output retry strategy should be available - - @output-retry-validation @feature-gated @stable-model - Scenario: Workflow Agent v2 output retry count validation is enforced - Given I am signed in as the default E2E admin - And Agent v2 workflow output retry count validation is available - And the Agent Builder stable chat model is available - And a workflow app with an Agent v2 node has been created via API - When I open the app from the app list - And I open the Agent v2 workflow node panel - Then Agent v2 workflow output retry count validation should be available diff --git a/e2e/features/agent-v2/support/test-materials.ts b/e2e/features/agent-v2/support/test-materials.ts index 1f047d4c1f4..a1b9a811ce8 100644 --- a/e2e/features/agent-v2/support/test-materials.ts +++ b/e2e/features/agent-v2/support/test-materials.ts @@ -10,14 +10,9 @@ export const agentBuilderTestMaterials = { invalidEnv: 'agent-invalid.env', buildInstruction: 'agent-build-instruction.txt', summarySkill: 'e2e-summary-skill/SKILL.md', - countBatch5: 'count_batch_5_valid_files', - countBatch6: 'count_batch_6_valid_files', - countTotal50: 'count_total_50_valid_files', - countTotalExtra1: 'count_total_extra_1_valid_file', } as const export const agentBuilderGeneratedTestMaterials = { - slowUploadFile: 'agent-slow-upload-file.txt', tooLargeFile: 'agent-too-large-file.txt', } as const @@ -30,10 +25,3 @@ export const getTooLargeAgentFilePath = () => sizeBytes: 16 * 1024 * 1024, seedText: 'E2E_TOO_LARGE_FILE_FIXTURE', }) - -export const getSlowUploadAgentFilePath = () => - getGeneratedTextMaterialPath({ - fileName: 'agent-slow-upload-file.txt', - sizeBytes: 2 * 1024 * 1024, - seedText: 'E2E_SLOW_UPLOAD_FILE_FIXTURE', - }) diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index 7a4c738ea73..6071f072876 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -175,7 +175,20 @@ const expectPageResponseOK = async (response: Response, action: string) => { } When('I discard the Agent v2 Build draft', async function (this: DifyWorld) { - await this.getPage().getByRole('button', { exact: true, name: 'Discard' }).click() + const page = this.getPage() + const agentId = getCurrentAgentId(this) + + await page.getByRole('button', { exact: true, name: 'Discard' }).click() + const confirmDialog = page.getByRole('alertdialog', { name: 'Clear session and discard changes?' }) + await expect(confirmDialog).toBeVisible() + + const discardResponsePromise = page.waitForResponse(response => ( + response.request().method() === 'DELETE' + && new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/build-draft`) + )) + + await confirmDialog.getByRole('button', { name: 'Confirm' }).click() + await expectPageResponseOK(await discardResponsePromise, 'Discard Agent v2 Build draft') }) When( diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index c41298fc84f..02ed9004d3c 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -282,10 +282,13 @@ Then('I should be on the Agent v2 configure page', async function (this: DifyWor Then('I should see the Agent v2 configure workspace', async function (this: DifyWorld) { const page = this.getPage() + const agentName = this.lastCreatedAgentName + if (!agentName) + throw new Error('No Agent v2 name found. Create an Agent v2 test agent first.') 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() + await expect(page.getByText(agentName, { exact: true })).toBeVisible() }) Then( diff --git a/e2e/features/step-definitions/agent-v2/files.steps.ts b/e2e/features/step-definitions/agent-v2/files.steps.ts index 15e2f15c42b..ddce18f62d7 100644 --- a/e2e/features/step-definitions/agent-v2/files.steps.ts +++ b/e2e/features/step-definitions/agent-v2/files.steps.ts @@ -140,41 +140,3 @@ Given('Agent v2 oversized file rejection is available', async function (this: Di Then('Agent v2 oversized file rejection should be available', async function (this: DifyWorld) { return skipOversizedFileRejection(this) }) - -async function skipTotalFileCountLimits(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 total file count limits are not defined for Agent config files in the current product contract.', - { - owner: 'product', - remediation: 'Define the Agent config file total-count limit and user-visible error before enabling this scenario.', - }, - ) -} - -Given('Agent v2 total file count limits are available', async function (this: DifyWorld) { - return skipTotalFileCountLimits(this) -}) - -Then('Agent v2 total file count limits should be available', async function (this: DifyWorld) { - return skipTotalFileCountLimits(this) -}) - -async function skipInProgressFileUploadRecovery(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 in-progress file upload recovery is not stable: the current dialog has no deterministic slow-upload fixture or user-visible navigation guard contract.', - { - owner: 'product/test-infra', - remediation: 'Define upload-in-progress navigation behavior and provide a deterministic slow upload fixture before enabling this scenario.', - }, - ) -} - -Given('Agent v2 in-progress file upload recovery is available', async function (this: DifyWorld) { - return skipInProgressFileUploadRecovery(this) -}) - -Then('Agent v2 in-progress file upload recovery should be available', async function (this: DifyWorld) { - return skipInProgressFileUploadRecovery(this) -}) diff --git a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts index f21f1944217..5619f5bae8a 100644 --- a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts +++ b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts @@ -330,44 +330,6 @@ async function expectAgentTaskOutputReference( await expect(page.getByText(unexpectedName, { exact: true })).toHaveCount(0) } -async function skipStandaloneOutputVariables(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Standalone Agent Output Variables are not available: output variables currently belong to Workflow Agent v2 nodes.', - { - owner: 'product', - remediation: 'Expose standalone Agent Output Variables or keep this scenario excluded until the product path exists.', - }, - ) -} - -Given('Agent v2 standalone Output Variables are available', async function (this: DifyWorld) { - return skipStandaloneOutputVariables(this) -}) - -Then('Agent v2 standalone Output Variables should be available', async function (this: DifyWorld) { - return skipStandaloneOutputVariables(this) -}) - -async function skipWorkflowOutputRetryStrategy(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 workflow Output Variables retry strategy is not available in the current editor UI.', - { - owner: 'product', - remediation: 'Expose user-visible retry strategy controls before enabling this scenario.', - }, - ) -} - -Given('Agent v2 workflow output retry strategy is available', async function (this: DifyWorld) { - return skipWorkflowOutputRetryStrategy(this) -}) - -Then('Agent v2 workflow output retry strategy should be available', async function (this: DifyWorld) { - return skipWorkflowOutputRetryStrategy(this) -}) - async function skipWorkflowTaskOutputReferenceDeletionConsistency(world: DifyWorld) { return skipBlockedPrecondition( world, @@ -392,22 +354,3 @@ Then( return skipWorkflowTaskOutputReferenceDeletionConsistency(this) }, ) - -async function skipWorkflowOutputRetryCountValidation(world: DifyWorld) { - return skipBlockedPrecondition( - world, - 'Agent v2 workflow Output Variables retry count validation is not reachable because retry strategy controls are not available in the current editor UI.', - { - owner: 'product', - remediation: 'Expose retry count controls and validation states before enabling this scenario.', - }, - ) -} - -Given('Agent v2 workflow output retry count validation is available', async function (this: DifyWorld) { - return skipWorkflowOutputRetryCountValidation(this) -}) - -Then('Agent v2 workflow output retry count validation should be available', async function (this: DifyWorld) { - return skipWorkflowOutputRetryCountValidation(this) -}) diff --git a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-1.txt b/e2e/fixtures/test-materials/count_batch_5_valid_files/file-1.txt deleted file mode 100644 index a64cd23552d..00000000000 --- a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-1.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 5 valid file 1 token E2E_BATCH_5_1 diff --git a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-2.txt b/e2e/fixtures/test-materials/count_batch_5_valid_files/file-2.txt deleted file mode 100644 index 1a65ed90e76..00000000000 --- a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-2.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 5 valid file 2 token E2E_BATCH_5_2 diff --git a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-3.txt b/e2e/fixtures/test-materials/count_batch_5_valid_files/file-3.txt deleted file mode 100644 index c5c00a2477e..00000000000 --- a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-3.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 5 valid file 3 token E2E_BATCH_5_3 diff --git a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-4.txt b/e2e/fixtures/test-materials/count_batch_5_valid_files/file-4.txt deleted file mode 100644 index 120e4ee335c..00000000000 --- a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-4.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 5 valid file 4 token E2E_BATCH_5_4 diff --git a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-5.txt b/e2e/fixtures/test-materials/count_batch_5_valid_files/file-5.txt deleted file mode 100644 index 4efaff6d828..00000000000 --- a/e2e/fixtures/test-materials/count_batch_5_valid_files/file-5.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 5 valid file 5 token E2E_BATCH_5_5 diff --git a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-1.txt b/e2e/fixtures/test-materials/count_batch_6_valid_files/file-1.txt deleted file mode 100644 index 6ce029a426b..00000000000 --- a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-1.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 6 valid file 1 token E2E_BATCH_6_1 diff --git a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-2.txt b/e2e/fixtures/test-materials/count_batch_6_valid_files/file-2.txt deleted file mode 100644 index 08620c1994f..00000000000 --- a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-2.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 6 valid file 2 token E2E_BATCH_6_2 diff --git a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-3.txt b/e2e/fixtures/test-materials/count_batch_6_valid_files/file-3.txt deleted file mode 100644 index 73deeefa755..00000000000 --- a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-3.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 6 valid file 3 token E2E_BATCH_6_3 diff --git a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-4.txt b/e2e/fixtures/test-materials/count_batch_6_valid_files/file-4.txt deleted file mode 100644 index 5b62b0d7075..00000000000 --- a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-4.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 6 valid file 4 token E2E_BATCH_6_4 diff --git a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-5.txt b/e2e/fixtures/test-materials/count_batch_6_valid_files/file-5.txt deleted file mode 100644 index c0192f87bb4..00000000000 --- a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-5.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 6 valid file 5 token E2E_BATCH_6_5 diff --git a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-6.txt b/e2e/fixtures/test-materials/count_batch_6_valid_files/file-6.txt deleted file mode 100644 index e3505c43d59..00000000000 --- a/e2e/fixtures/test-materials/count_batch_6_valid_files/file-6.txt +++ /dev/null @@ -1 +0,0 @@ -Batch 6 valid file 6 token E2E_BATCH_6_6 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-01.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-01.txt deleted file mode 100644 index db1a55a0021..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-01.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 01 token E2E_TOTAL_50_01 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-02.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-02.txt deleted file mode 100644 index bdcd785ab8f..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-02.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 02 token E2E_TOTAL_50_02 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-03.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-03.txt deleted file mode 100644 index cf00c92b0a3..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-03.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 03 token E2E_TOTAL_50_03 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-04.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-04.txt deleted file mode 100644 index 01522864989..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-04.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 04 token E2E_TOTAL_50_04 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-05.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-05.txt deleted file mode 100644 index 22a118ddd0f..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-05.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 05 token E2E_TOTAL_50_05 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-06.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-06.txt deleted file mode 100644 index 3cee5574426..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-06.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 06 token E2E_TOTAL_50_06 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-07.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-07.txt deleted file mode 100644 index 381bf4d24d5..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-07.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 07 token E2E_TOTAL_50_07 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-08.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-08.txt deleted file mode 100644 index 8cc4d21fc86..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-08.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 08 token E2E_TOTAL_50_08 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-09.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-09.txt deleted file mode 100644 index 490cc4b779c..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-09.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 09 token E2E_TOTAL_50_09 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-10.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-10.txt deleted file mode 100644 index fe4089625f5..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-10.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 10 token E2E_TOTAL_50_10 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-11.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-11.txt deleted file mode 100644 index b5a9ae2ea3f..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-11.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 11 token E2E_TOTAL_50_11 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-12.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-12.txt deleted file mode 100644 index 33e0f0b77c0..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-12.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 12 token E2E_TOTAL_50_12 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-13.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-13.txt deleted file mode 100644 index 0bf532f9a32..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-13.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 13 token E2E_TOTAL_50_13 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-14.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-14.txt deleted file mode 100644 index 60b3cb0d751..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-14.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 14 token E2E_TOTAL_50_14 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-15.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-15.txt deleted file mode 100644 index 15c0e3769be..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-15.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 15 token E2E_TOTAL_50_15 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-16.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-16.txt deleted file mode 100644 index 6c80e6e66bb..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-16.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 16 token E2E_TOTAL_50_16 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-17.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-17.txt deleted file mode 100644 index 9ac689f667a..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-17.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 17 token E2E_TOTAL_50_17 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-18.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-18.txt deleted file mode 100644 index 3a9c6725090..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-18.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 18 token E2E_TOTAL_50_18 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-19.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-19.txt deleted file mode 100644 index 35d544714c8..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-19.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 19 token E2E_TOTAL_50_19 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-20.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-20.txt deleted file mode 100644 index cc53594e52f..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-20.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 20 token E2E_TOTAL_50_20 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-21.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-21.txt deleted file mode 100644 index 2e0af50430c..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-21.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 21 token E2E_TOTAL_50_21 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-22.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-22.txt deleted file mode 100644 index fe09be37a12..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-22.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 22 token E2E_TOTAL_50_22 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-23.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-23.txt deleted file mode 100644 index 70bb91ee326..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-23.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 23 token E2E_TOTAL_50_23 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-24.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-24.txt deleted file mode 100644 index a8ba2e60d61..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-24.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 24 token E2E_TOTAL_50_24 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-25.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-25.txt deleted file mode 100644 index ddfe2afed46..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-25.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 25 token E2E_TOTAL_50_25 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-26.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-26.txt deleted file mode 100644 index 1a56d928a9a..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-26.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 26 token E2E_TOTAL_50_26 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-27.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-27.txt deleted file mode 100644 index a8182ad2e71..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-27.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 27 token E2E_TOTAL_50_27 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-28.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-28.txt deleted file mode 100644 index a6f78f011db..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-28.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 28 token E2E_TOTAL_50_28 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-29.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-29.txt deleted file mode 100644 index 7a76d201194..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-29.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 29 token E2E_TOTAL_50_29 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-30.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-30.txt deleted file mode 100644 index 5a37417ca8c..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-30.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 30 token E2E_TOTAL_50_30 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-31.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-31.txt deleted file mode 100644 index 1baa8eb8c7b..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-31.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 31 token E2E_TOTAL_50_31 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-32.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-32.txt deleted file mode 100644 index e5ff1a42437..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-32.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 32 token E2E_TOTAL_50_32 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-33.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-33.txt deleted file mode 100644 index dcbc2995748..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-33.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 33 token E2E_TOTAL_50_33 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-34.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-34.txt deleted file mode 100644 index fefc133eed1..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-34.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 34 token E2E_TOTAL_50_34 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-35.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-35.txt deleted file mode 100644 index 1c1a93172cb..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-35.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 35 token E2E_TOTAL_50_35 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-36.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-36.txt deleted file mode 100644 index d7e0bab1aaf..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-36.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 36 token E2E_TOTAL_50_36 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-37.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-37.txt deleted file mode 100644 index 6a78127243f..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-37.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 37 token E2E_TOTAL_50_37 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-38.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-38.txt deleted file mode 100644 index fc5ef9123db..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-38.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 38 token E2E_TOTAL_50_38 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-39.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-39.txt deleted file mode 100644 index 37765df65e5..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-39.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 39 token E2E_TOTAL_50_39 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-40.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-40.txt deleted file mode 100644 index 74de5e2ed4e..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-40.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 40 token E2E_TOTAL_50_40 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-41.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-41.txt deleted file mode 100644 index 13d3dee7c51..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-41.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 41 token E2E_TOTAL_50_41 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-42.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-42.txt deleted file mode 100644 index befc45f2387..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-42.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 42 token E2E_TOTAL_50_42 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-43.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-43.txt deleted file mode 100644 index 521ad583a5a..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-43.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 43 token E2E_TOTAL_50_43 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-44.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-44.txt deleted file mode 100644 index e4b315b675f..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-44.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 44 token E2E_TOTAL_50_44 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-45.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-45.txt deleted file mode 100644 index c97738e4fac..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-45.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 45 token E2E_TOTAL_50_45 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-46.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-46.txt deleted file mode 100644 index 22b73767b42..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-46.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 46 token E2E_TOTAL_50_46 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-47.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-47.txt deleted file mode 100644 index b327026efd0..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-47.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 47 token E2E_TOTAL_50_47 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-48.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-48.txt deleted file mode 100644 index 5458e0cf222..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-48.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 48 token E2E_TOTAL_50_48 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-49.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-49.txt deleted file mode 100644 index 90bf454dd5f..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-49.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 49 token E2E_TOTAL_50_49 diff --git a/e2e/fixtures/test-materials/count_total_50_valid_files/file-50.txt b/e2e/fixtures/test-materials/count_total_50_valid_files/file-50.txt deleted file mode 100644 index b5760aed272..00000000000 --- a/e2e/fixtures/test-materials/count_total_50_valid_files/file-50.txt +++ /dev/null @@ -1 +0,0 @@ -Total 50 valid file 50 token E2E_TOTAL_50_50 diff --git a/e2e/fixtures/test-materials/count_total_extra_1_valid_file/file-01.txt b/e2e/fixtures/test-materials/count_total_extra_1_valid_file/file-01.txt deleted file mode 100644 index 4df2e230c05..00000000000 --- a/e2e/fixtures/test-materials/count_total_extra_1_valid_file/file-01.txt +++ /dev/null @@ -1 +0,0 @@ -Total extra valid file token E2E_TOTAL_EXTRA_1 diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5207eae92bc..81d07357845 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1183,7 +1183,7 @@ "count": 2 }, "ts/no-explicit-any": { - "count": 17 + "count": 12 } }, "web/app/components/base/chat/chat/log/index.tsx": { diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index bc74f6ebf56..3f3bee9fdd2 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -921,6 +921,7 @@ export type AgentLogMessageItemResponse = { } export type AgentThought = { + answer?: string | null chain_id?: string | null created_at?: number | null files: Array diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 6be9ce9e118..d62307a61d0 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -802,6 +802,7 @@ export const zJsonValue = z * AgentThought */ export const zAgentThought = z.object({ + answer: z.string().nullish(), chain_id: z.string().nullish(), created_at: z.int().nullish(), files: z.array(z.string()), diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 627e65bfc81..f18eec3f8b9 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -1648,6 +1648,7 @@ export type ConversationVariableResponse = { } export type AgentThought = { + answer?: string | null chain_id?: string | null created_at?: number | null files: Array diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index f9742c91d64..91d6f6bd01e 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1342,6 +1342,7 @@ export const zPaginatedConversationVariableResponse = z.object({ * AgentThought */ export const zAgentThought = z.object({ + answer: z.string().nullish(), chain_id: z.string().nullish(), created_at: z.int().nullish(), files: z.array(z.string()), diff --git a/packages/contracts/generated/api/console/installed-apps/types.gen.ts b/packages/contracts/generated/api/console/installed-apps/types.gen.ts index 4692d77db63..59b4619c11a 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -247,6 +247,7 @@ export type InstalledAppInfoResponse = { } export type AgentThought = { + answer?: string | null chain_id?: string | null created_at?: number | null files: Array diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index 6e5680ab1b8..60c4f1448c4 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -266,6 +266,7 @@ export const zInstalledAppListResponse = z.object({ * AgentThought */ export const zAgentThought = z.object({ + answer: z.string().nullish(), chain_id: z.string().nullish(), created_at: z.int().nullish(), files: z.array(z.string()), diff --git a/packages/contracts/generated/api/service/types.gen.ts b/packages/contracts/generated/api/service/types.gen.ts index b9497dabca3..4a58a619741 100644 --- a/packages/contracts/generated/api/service/types.gen.ts +++ b/packages/contracts/generated/api/service/types.gen.ts @@ -5,6 +5,7 @@ export type ClientOptions = { } export type AgentThought = { + answer?: string | null chain_id?: string | null created_at?: number | null files: Array diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index cedeb77dab9..a5da8668e5c 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -1129,6 +1129,7 @@ export const zJsonValue = z * AgentThought */ export const zAgentThought = z.object({ + answer: z.string().nullish(), chain_id: z.string().nullish(), created_at: z.int().nullish(), files: z.array(z.string()), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 44a461c1592..69e4ebdecd0 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -18,6 +18,7 @@ export type AccessTokenResultResponse = { } export type AgentThought = { + answer?: string | null chain_id?: string | null created_at?: number | null files: Array diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 55cefb12e14..c663be89564 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -363,6 +363,7 @@ export const zJsonValue = z * AgentThought */ export const zAgentThought = z.object({ + answer: z.string().nullish(), chain_id: z.string().nullish(), created_at: z.int().nullish(), files: z.array(z.string()), diff --git a/web/AGENTS.md b/web/AGENTS.md index 2281cfab4bf..c6a0b184fe4 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -9,6 +9,10 @@ - User-facing strings must use `web/i18n/en-US/` keys instead of hardcoded text. - When adding or renaming an i18n key, update all supported locale files with correct localized values. Do not leave fallback English in non-English locales unless the repo already intentionally does so for that exact key. +## Backend API Calls + +- For new backend calls, and for surfaces already migrated to generated contracts, use `consoleQuery` / `consoleClient` from `@/service/client`. Do not add handwritten REST helpers, handwritten API types, mock-backed app state, or direct edits to generated contract files. + ## Overlay Components (Mandatory) - `../packages/dify-ui/README.md` is the permanent contract for overlay primitives, portals, root `isolation: isolate`, and the `z-50` / `z-60` layering. @@ -16,6 +20,10 @@ - In new or modified code, use only overlay primitives from `@langgenius/dify-ui/*`. - Do not introduce overlay imports from `@/app/components/base/*`; when touching existing callers, migrate them. +## UI Components + +- Use `@langgenius/dify-ui/*` primitives and primitive data/CSS selectors first. Add call-site Tailwind only for real design deltas, avoid arbitrary values when token utilities exist, and keep focus rings visible without making inert layout regions focusable. + ## SVG Icons (Mandatory) - New custom SVG icons must be added under `../packages/iconify-collections/assets/...`. @@ -38,15 +46,6 @@ - Keep storage keys and raw/custom formats in the owner module; callers should import the named storage hooks instead of scattering direct storage access. - Do not add ad hoc global event listeners for shared state. Prefer atoms, existing stores, or a shared subscription hook so listeners are centralized and deduplicated. -## Agent V2 Frontend - -- Keep Agent V2 separate from legacy workflow Agent. Use `web/features/agent-v2`, `web/app/components/workflow/nodes/agent-v2`, the `agent_node_kind: 'dify_agent'` and `version: '2'` payload discriminator, and `BlockEnum.AgentV2` where the graph type is already migrated. Do not bridge Agent V2 to legacy `agent_strategy_*` behavior or data shapes. -- Use generated contracts and `consoleQuery` / `consoleClient` from `@/service/client` for Agent V2 backend calls. Do not add handwritten REST helpers, handwritten API types, mock-backed app state, or direct edits to generated contract files. -- Treat TanStack Query as the server source of truth. Scope editable drafts with an instance-level `AgentComposerProvider`, hydrate Jotai `originalDraft`, `publishedDraft`, and `draft` from contract data, and compute dirty or unpublished state from those draft snapshots. -- Keep transitional defaults and mock data at the owning surface, such as the configure page or workflow node, not in shared composer defaults. -- Use `@langgenius/dify-ui/*` primitives and primitive data/CSS selectors first. Add call-site Tailwind only for real design deltas, avoid arbitrary values when token utilities exist, and keep focus rings visible without making inert layout regions focusable. -- Keep Agent V2 copy in the `agentV2` i18n namespace, currently backed by `agent-v-2.json` in the maintained locale set. - ## Automated Test Generation - Use `./docs/test.md` as the canonical instruction set for generating frontend automated tests. diff --git a/web/app/(shareLayout)/agent/[token]/page.tsx b/web/app/(shareLayout)/agent/[token]/page.tsx index 5dc2777c9c4..d46ebf0ba56 100644 --- a/web/app/(shareLayout)/agent/[token]/page.tsx +++ b/web/app/(shareLayout)/agent/[token]/page.tsx @@ -2,12 +2,13 @@ import * as React from 'react' import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history' +import { AgentRosterResponseContent } from '@/app/components/base/chat/chat/answer/agent-roster-response-content' import AuthenticatedLayout from '../../components/authenticated-layout' function Agent() { return ( - + ) } diff --git a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx index 459df5d0638..c185fe2bd40 100644 --- a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx +++ b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx @@ -7,6 +7,12 @@ import * as React from 'react' import { AppModeEnum, ModelModeType } from '@/types/app' import ConfigurationView from '../configuration-view' +const mockIsAgentV2Enabled = vi.hoisted(() => vi.fn(() => false)) + +vi.mock('@/features/agent-v2/feature-flag', () => ({ + isAgentV2Enabled: () => mockIsAgentV2Enabled(), +})) + vi.mock('@/app/components/app/app-publisher/features-wrapper', () => ({ default: () =>
, })) @@ -254,6 +260,7 @@ const createViewModel = (overrides: Partial = {}): Confi describe('ConfigurationView', () => { beforeEach(() => { vi.clearAllMocks() + mockIsAgentV2Enabled.mockReturnValue(false) }) it('should render a loading state before configuration data is ready', () => { @@ -280,4 +287,31 @@ describe('ConfigurationView', () => { expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false) }) + + it('should show the legacy Agent badge for legacy Agent apps when Agent v2 is enabled', async () => { + mockIsAgentV2Enabled.mockReturnValue(true) + const contextValue = createContextValue() + contextValue.mode = AppModeEnum.AGENT_CHAT + + render() + + const badge = screen.getByRole('button', { name: 'appDebug.legacyAgentBadge.description' }) + expect(badge).toHaveTextContent('appDebug.legacyAgentBadge.label') + + fireEvent.click(badge) + + expect(await screen.findByText('appDebug.legacyAgentBadge.description')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('href', '/roster') + expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('target', '_blank') + expect(screen.getByRole('link', { name: /appDebug\.legacyAgentBadge\.action/ })).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should not show the legacy Agent badge when Agent v2 is disabled', () => { + const contextValue = createContextValue() + contextValue.mode = AppModeEnum.AGENT_CHAT + + render() + + expect(screen.queryByText('appDebug.legacyAgentBadge.label')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view.tsx index 90f2d429720..b100b90f190 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view.tsx @@ -21,6 +21,11 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import * as React from 'react' import { useTranslation } from 'react-i18next' import AppPublisher from '@/app/components/app/app-publisher/features-wrapper' @@ -37,8 +42,47 @@ import ModelParameterModal from '@/app/components/header/account-setting/model-p import PluginDependency from '@/app/components/workflow/plugin-dependency' import ConfigContext from '@/context/debug-configuration' import { MittProvider } from '@/context/mitt-context-provider' +import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' +import Link from '@/next/link' import { AppModeEnum, ModelModeType } from '@/types/app' +function LegacyAgentBadge() { + const { t } = useTranslation() + const description = t('legacyAgentBadge.description', { ns: 'appDebug' }) + + return ( + + + + {t('legacyAgentBadge.label', { ns: 'appDebug' })} + + +
{description}
+ + {t('legacyAgentBadge.action', { ns: 'appDebug' })} + + +
+
+ ) +} + const ConfigurationView: FC = ({ appPublisherProps, contextValue, @@ -76,6 +120,7 @@ const ConfigurationView: FC = ({ }) => { const { t } = useTranslation() const debugWithMultipleModel = appPublisherProps.debugWithMultipleModel + const showLegacyAgentBadge = isAgentV2Enabled() && contextValue.mode === AppModeEnum.AGENT_CHAT if (showLoading) { return ( @@ -93,15 +138,14 @@ const ConfigurationView: FC = ({
-
+
{t('orchestrate', { ns: 'appDebug' })}
-
- {isAdvancedMode && ( -
- {t('promptMode.advanced', { ns: 'appDebug' })} -
- )} -
+ {showLegacyAgentBadge && } + {isAdvancedMode && ( +
+ {t('promptMode.advanced', { ns: 'appDebug' })} +
+ )}
{isAgent && ( diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index e621a5aaacd..1a1a3a1a0cf 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -57,6 +57,12 @@ type SettingsSiteInfo = Pick< | 'use_icon_as_answer_icon' > +type SettingsAppIconSelection = AppIconSelection | { + type: 'link' + icon: string + url: string +} + export type SettingsAppInfo = { id: string mode: AppModeEnum @@ -128,12 +134,16 @@ const createInputInfo = (appInfo: ISettingsModalProps['appInfo']) => { } } -const createAppIcon = (appInfo: ISettingsModalProps['appInfo']): AppIconSelection => { +const createAppIcon = (appInfo: ISettingsModalProps['appInfo']): SettingsAppIconSelection => { const { icon_type, icon, icon_background, icon_url } = appInfo.site - return icon_type === 'image' - ? { type: 'image', url: icon_url!, fileId: icon } - : { type: 'emoji', icon, background: icon_background! } + if (icon_type === 'image') + return { type: 'image', url: icon_url!, fileId: icon } + + if (icon_type === 'link') + return { type: 'link', icon, url: icon } + + return { type: 'emoji', icon, background: icon_background! } } const getSettingsResetKey = (appInfo: ISettingsModalProps['appInfo']) => JSON.stringify([ @@ -175,7 +185,7 @@ const SettingsModal: FC = ({ const { t } = useTranslation() const [showAppIconPicker, setShowAppIconPicker] = useState(false) - const [appIcon, setAppIcon] = useState(nextAppIcon) + const [appIcon, setAppIcon] = useState(nextAppIcon) const [previousIsShow, setPreviousIsShow] = useState(isShow) const [previousSettingsResetKey, setPreviousSettingsResetKey] = useState(settingsResetKey) @@ -302,7 +312,7 @@ const SettingsModal: FC = ({ ? '' : (inputInfo.inputPlaceholder ?? '').slice(0, INPUT_PLACEHOLDER_MAX_LENGTH), icon_type: appIcon.type, - icon: appIcon.type === 'emoji' ? appIcon.icon : appIcon.fileId, + icon: appIcon.type === 'image' ? appIcon.fileId : appIcon.icon, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, show_workflow_steps: inputInfo.show_workflow_steps, use_icon_as_answer_icon: inputInfo.use_icon_as_answer_icon, @@ -366,10 +376,10 @@ const SettingsModal: FC = ({ size="xxl" onClick={() => { setShowAppIconPicker(true) }} className="mt-2 cursor-pointer" - iconType={appIcon.type} + iconType={appIcon.type === 'link' ? 'image' : appIcon.type} icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon} - background={appIcon.type === 'image' ? undefined : appIcon.background} - imageUrl={appIcon.type === 'image' ? appIcon.url : undefined} + background={appIcon.type === 'emoji' ? appIcon.background : undefined} + imageUrl={appIcon.type === 'emoji' ? undefined : appIcon.url} />
{/* description */} diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index be62a5a2115..c8752a7a6e7 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -6,6 +6,7 @@ import type { HumanInputFormData } from '@/types/workflow' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { InputVarType } from '@/app/components/workflow/types' import { + fetchChatList, fetchSuggestedQuestions, stopChatMessageResponding, submitHumanInputForm, @@ -42,6 +43,7 @@ vi.mock('../../utils', () => ({ })) vi.mock('@/service/share', () => ({ + fetchChatList: vi.fn(), fetchSuggestedQuestions: vi.fn(), getUrl: vi.fn(() => 'mock-url'), stopChatMessageResponding: vi.fn(), @@ -663,6 +665,55 @@ describe('ChatWrapper', () => { expect(fetchSuggestedQuestions).toHaveBeenCalledWith('response-id', 'webApp', 'test-app-id') }) + it('should fetch current conversation messages after new agent send completes', async () => { + const handleSend = vi.fn() + vi.mocked(fetchChatList).mockResolvedValue({ data: [] }) + vi.mocked(useChat).mockReturnValue({ + ...defaultChatHookReturn, + handleSend, + chatList: [{ id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }], + suggestedQuestions: ['Q1'], + } as unknown as ChatHookReturn) + + vi.mocked(useChatWithHistoryContext).mockReturnValue({ + ...defaultContextValue, + currentConversationId: '', + isNewAgent: true, + }) + + render() + + fireEvent.click(await screen.findByText('Q1')) + + const options = handleSend.mock.calls[0]![2] + await options.onGetConversationMessages('conversation-1') + + expect(fetchChatList).toHaveBeenCalledWith('conversation-1', 'webApp', 'test-app-id') + }) + + it('should not fetch current conversation messages for non-new-agent chat', async () => { + const handleSend = vi.fn() + vi.mocked(useChat).mockReturnValue({ + ...defaultChatHookReturn, + handleSend, + chatList: [{ id: '1', isOpeningStatement: true, content: 'Welcome', suggestedQuestions: ['Q1'] }], + suggestedQuestions: ['Q1'], + } as unknown as ChatHookReturn) + + vi.mocked(useChatWithHistoryContext).mockReturnValue({ + ...defaultContextValue, + currentConversationId: '', + isNewAgent: false, + }) + + render() + + fireEvent.click(await screen.findByText('Q1')) + + const options = handleSend.mock.calls[0]![2] + expect(options.onGetConversationMessages).toBeUndefined() + }) + it('should call fetchSuggestedQuestions in doSwitchSibling', async () => { const handleSwitchSibling = vi.fn() vi.mocked(useChat).mockReturnValue({ diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 966528c93f2..d9c8628fd4c 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -18,6 +18,7 @@ import { Markdown } from '@/app/components/base/markdown' import { InputVarType } from '@/app/components/workflow/types' import { AppSourceType, + fetchChatList, fetchSuggestedQuestions, getUrl, stopChatMessageResponding, @@ -57,6 +58,8 @@ const ChatWrapper = () => { setIsResponding, allInputsHidden, initUserVariables, + isNewAgent, + renderAgentContent, } = useChatWithHistoryContext() const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp @@ -98,7 +101,7 @@ const ChatWrapper = () => { clearChatList, setClearChatList, undefined, - { timezone }, + { isNewAgent, timezone }, ) const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current const inputDisabled = useMemo(() => { @@ -205,12 +208,15 @@ const ChatWrapper = () => { getUrl('chat-messages', appSourceType, appId || ''), data, { + onGetConversationMessages: isNewAgent + ? conversationId => fetchChatList(conversationId, appSourceType, appId) + : undefined, onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted, isPublicAPI: appSourceType === AppSourceType.webApp, }, ) - }, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted]) + }, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted, isNewAgent]) const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => { const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)! @@ -418,6 +424,7 @@ const ChatWrapper = () => { switchSibling={doSwitchSibling} inputDisabled={inputDisabled} sidebarCollapseState={sidebarCollapseState} + renderAgentContent={renderAgentContent} questionIcon={ initUserVariables?.avatar_url ? ( diff --git a/web/app/components/base/chat/chat-with-history/context.ts b/web/app/components/base/chat/chat-with-history/context.ts index 49dd06ca527..c27b23553bf 100644 --- a/web/app/components/base/chat/chat-with-history/context.ts +++ b/web/app/components/base/chat/chat-with-history/context.ts @@ -1,6 +1,7 @@ 'use client' import type { RefObject } from 'react' +import type { ChatProps } from '../chat' import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' import type { Callback, @@ -60,6 +61,8 @@ export type ChatWithHistoryContextValue = { name?: string avatar_url?: string } + isNewAgent?: boolean + renderAgentContent?: ChatProps['renderAgentContent'] } export const ChatWithHistoryContext = createContext({ @@ -95,5 +98,6 @@ export const ChatWithHistoryContext = createContext setCurrentConversationInputs: noop, allInputsHidden: false, initUserVariables: {}, + isNewAgent: false, }) export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext) diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index 41cdeaa9193..9e003889c5a 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { ChatProps } from '../chat' import type { InstalledApp } from '@/models/explore' import { cn } from '@langgenius/dify-ui/cn' import { @@ -100,10 +101,14 @@ const ChatWithHistory: FC = ({ type ChatWithHistoryWrapProps = { installedAppInfo?: InstalledApp className?: string + isNewAgent?: boolean + renderAgentContent?: ChatProps['renderAgentContent'] } const ChatWithHistoryWrap: FC = ({ installedAppInfo, className, + isNewAgent = false, + renderAgentContent, }) => { const media = useBreakpoints() const isMobile = media === MediaType.mobile @@ -190,6 +195,8 @@ const ChatWithHistoryWrap: FC = ({ setCurrentConversationInputs, allInputsHidden, initUserVariables, + isNewAgent, + renderAgentContent, }} > @@ -200,11 +207,15 @@ const ChatWithHistoryWrap: FC = ({ const ChatWithHistoryWrapWithCheckToken: FC = ({ installedAppInfo, className, + isNewAgent, + renderAgentContent, }) => { return ( ) } diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index ebcb6a1d1eb..f1a2e34c6c4 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -444,7 +444,7 @@ describe('useChat', () => { // onThought callbacks.onThought({ id: 'th-1', message_id: 'm-2', thought: 'thinking...', message_files: [] }) - // onData (for agent mode, appends to thought) + // onData appends to answer content, not agent thought. callbacks.onData(' detailed', false, { messageId: 'm-2' }) // onThought (update same thought) @@ -476,7 +476,7 @@ describe('useChat', () => { const lastResponse = result.current.chatList[1] expect(lastResponse!.agent_thoughts).toHaveLength(2) - expect(lastResponse!.agent_thoughts![0]!.thought).toContain('thinking...') + expect(lastResponse!.agent_thoughts![0]!.thought).toBe('thinking... detailed updated') expect(lastResponse!.agent_thoughts![1]!.thought).toContain('second thought') expect(lastResponse!.workflowProcess?.tracing).toHaveLength(3) // node, iteration, loop }) @@ -1094,7 +1094,7 @@ describe('useChat', () => { }) const lastResponse = result.current.chatList[result.current.chatList.length - 1] - expect(lastResponse!.agent_thoughts![0]!.thought).toContain('resumed') + expect(lastResponse!.agent_thoughts![0]!.thought).toBe('thinking updated') expect(lastResponse!.workflowProcess?.tracing?.length).toBeGreaterThan(0) expect(lastResponse!.workflowProcess?.status).toBe('paused') @@ -1897,6 +1897,137 @@ describe('useChat', () => { expect(lastResponse!.more?.tokens_per_second).toBeUndefined() }) + it('should handle completed conversation history when message log is missing', async () => { + let callbacks: HookCallbacks + vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const onGetConversationMessages = vi.fn().mockResolvedValue({ + data: [{ + id: 'm-without-log', + answer: 'final answer', + agent_thoughts: [], + created_at: Date.now(), + inputs: {}, + query: 'hi', + }], + }) + + const { result } = renderHook(() => useChat()) + act(() => { + result.current.handleSend('test-url', { query: 'fetch test missing log' }, { + onGetConversationMessages, + }) + }) + + await act(async () => { + callbacks.onData(' data', true, { messageId: 'm-without-log', conversationId: 'c-without-log' }) + await callbacks.onCompleted() + }) + + const lastResponse = result.current.chatList[1] + expect(lastResponse!.content).toBe('final answer') + expect(lastResponse!.log).toEqual([{ + role: 'assistant', + text: 'final answer', + files: [], + }]) + expect(lastResponse!.more?.tokens).toBe(0) + expect(lastResponse!.more?.latency).toBe('0.00') + expect(lastResponse!.more?.tokens_per_second).toBeUndefined() + }) + + it('should replace new agent streaming response parts with completed conversation history', async () => { + let callbacks: HookCallbacks + vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const onGetConversationMessages = vi.fn().mockResolvedValue({ + data: [{ + id: 'm-new-agent-history', + answer: 'history top-level answer', + message: [{ role: 'user', text: 'hi' }], + agent_thoughts: [ + { + id: 'history-thought', + thought: 'history thought', + answer: 'history agent answer', + tool: '', + tool_input: '', + observation: '', + position: 1, + }, + ], + message_files: [{ + id: 'history-file', + belongs_to: 'assistant', + type: 'image', + url: 'history.png', + }], + retriever_resources: [{ id: 'history-citation', content: 'history citation' }], + metadata: { reasoning: { history: 'history reasoning' } }, + created_at: Date.now(), + answer_tokens: 10, + message_tokens: 5, + provider_response_latency: 0.5, + workflow_run_id: 'history-workflow-run', + feedback: { rating: 'like' }, + inputs: {}, + query: 'hi', + }], + }) + + const { result } = renderHook(() => useChat( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { isNewAgent: true }, + )) + + act(() => { + result.current.handleSend('test-url', { query: 'new agent history' }, { + onGetConversationMessages, + }) + }) + + await act(async () => { + callbacks.onWorkflowStarted({ workflow_run_id: 'stream-workflow-run', task_id: 'stream-task' }) + callbacks.onThought({ id: 'stream-thought', thought: 'stream thought' }) + callbacks.onData(' stream answer', true, { event: 'agent_message', messageId: 'm-new-agent-history', conversationId: 'c-new-agent-history' }) + callbacks.onReasoning({ data: { message_id: 'm-new-agent-history', node_id: 'stream', reasoning: 'stream reasoning' } }) + callbacks.onMessageEnd({ + id: 'm-new-agent-history', + metadata: { retriever_resources: [{ id: 'stream-citation', content: 'stream citation' }] }, + files: [{ id: 'stream-file', type: 'image', url: 'stream.png' }], + }) + await callbacks.onCompleted() + }) + + const lastResponse = result.current.chatList[1] + expect(lastResponse!.content).toBe('') + expect(lastResponse!.agent_response_parts).toBeUndefined() + expect(lastResponse!.workflow_run_id).toBe('history-workflow-run') + expect(lastResponse!.workflowProcess).toBeUndefined() + expect(lastResponse!.citation).toEqual([{ id: 'history-citation', content: 'history citation' }]) + expect(lastResponse!.reasoningContent).toEqual({ history: 'history reasoning' }) + expect(lastResponse!.message_files).toEqual([expect.objectContaining({ id: 'history-file' })]) + expect(lastResponse!.allFiles).toBeUndefined() + expect(lastResponse!.feedback).toEqual({ rating: 'like' }) + expect(lastResponse!.agent_thoughts).toEqual([ + expect.objectContaining({ + id: 'history-thought', + thought: 'history thought', + answer: 'history agent answer', + }), + ]) + }) + it('should handle onCompleted using agent thought when thought matches answer', async () => { let callbacks: HookCallbacks vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { @@ -1977,7 +2108,7 @@ describe('useChat', () => { expect(lastResponse!.workflowProcess?.status).toBe('succeeded') }) - it('should cover onThought creating tracing and appending message correctly when isAgentMode=true', () => { + it('should append message chunks to agent thought by default after agent thoughts', () => { let callbacks: HookCallbacks vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { callbacks = options as HookCallbacks @@ -1994,14 +2125,56 @@ describe('useChat', () => { // onThought when array is implicitly empty callbacks.onThought({ id: 'th-1', thought: 'initial thought' }) - // onData which appends to last thought + // onData comes from message/agent_message events and should render as answer content. callbacks.onData(' appended', false, { messageId: 'm-thought' }) }) const lastResponse = result.current.chatList[result.current.chatList.length - 1] + expect(lastResponse!.content).toBe('') expect(lastResponse!.agent_thoughts).toHaveLength(1) expect(lastResponse!.agent_thoughts![0]!.thought).toBe('initial thought appended') }) + + it('should preserve response part order for new agent when agent thoughts and messages interleave', () => { + let callbacks: HookCallbacks + vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const { result } = renderHook(() => useChat( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { isNewAgent: true }, + )) + act(() => { + result.current.handleSend('url', { query: 'agent onThought' }, {}) + }) + + act(() => { + callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' }) + callbacks.onThought({ id: 'th-1', thought: 'initial thought' }) + callbacks.onData(' first answer', false, { event: 'agent_message', messageId: 'm-thought' }) + callbacks.onData(' continued answer', false, { event: 'message', messageId: 'm-thought' }) + callbacks.onThought({ id: 'th-2', thought: 'second thought' }) + callbacks.onData(' second answer', false, { event: 'agent_message', messageId: 'm-thought' }) + }) + + const lastResponse = result.current.chatList[result.current.chatList.length - 1] + expect(lastResponse!.content).toBe('') + expect(lastResponse!.agent_thoughts).toHaveLength(2) + expect(lastResponse!.agent_thoughts![0]!.thought).toBe('initial thought') + expect(lastResponse!.agent_response_parts).toEqual([ + { type: 'thought', thought: expect.objectContaining({ id: 'th-1', thought: 'initial thought' }) }, + { type: 'message', content: ' first answer continued answer' }, + { type: 'thought', thought: expect.objectContaining({ id: 'th-2', thought: 'second thought' }) }, + { type: 'message', content: ' second answer' }, + ]) + }) }) it('should cover produceChatTreeNode traversing deeply nested child nodes to find the target item', () => { diff --git a/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx new file mode 100644 index 00000000000..7b22218b895 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx @@ -0,0 +1,111 @@ +import type { ChatItem } from '../../../types' +import { render, screen, waitFor } from '@testing-library/react' +import { AgentRosterResponseContent } from '../agent-roster-response-content' + +describe('AgentRosterResponseContent', () => { + it('should render historical agent thought answer as markdown instead of thought process', async () => { + const item = { + id: 'answer-history', + content: '', + isAnswer: true, + agent_thoughts: [ + { + id: 'thought-with-answer', + thought: 'internal thought should not render', + answer: '**history answer**', + tool: '', + tool_input: '', + observation: '', + message_id: 'answer-history', + conversation_id: 'conversation-history', + position: 1, + }, + ], + } satisfies ChatItem + + render() + + await waitFor(() => { + expect(screen.getByTestId('agent-roster-response-content')).toHaveTextContent('history answer') + }) + + expect(screen.queryByText('internal thought should not render')).not.toBeInTheDocument() + }) + + it('should render new agent response parts in event order when thoughts and messages interleave', async () => { + const item = { + id: 'answer-1', + content: 'first answer second answer', + isAnswer: true, + agent_thoughts: [ + { + id: 'thought-1', + thought: 'first thought', + tool: '', + tool_input: '', + observation: '', + message_id: 'answer-1', + conversation_id: 'conversation-1', + position: 1, + }, + { + id: 'thought-2', + thought: 'second thought', + tool: '', + tool_input: '', + observation: '', + message_id: 'answer-1', + conversation_id: 'conversation-1', + position: 2, + }, + ], + agent_response_parts: [ + { + type: 'thought', + thought: { + id: 'thought-1', + thought: 'first thought', + tool: '', + tool_input: '', + observation: '', + message_id: 'answer-1', + conversation_id: 'conversation-1', + position: 1, + }, + }, + { + type: 'message', + content: 'first answer', + }, + { + type: 'thought', + thought: { + id: 'thought-2', + thought: 'second thought', + tool: '', + tool_input: '', + observation: '', + message_id: 'answer-1', + conversation_id: 'conversation-1', + position: 2, + }, + }, + { + type: 'message', + content: 'second answer', + }, + ], + } satisfies ChatItem + + render() + + await waitFor(() => { + expect(screen.getByTestId('agent-roster-response-content')).toHaveTextContent('second answer') + }) + + const content = screen.getByTestId('agent-roster-response-content').textContent ?? '' + expect(content.indexOf('first thought')).toBeLessThan(content.indexOf('first answer')) + expect(content.indexOf('first answer')).toBeLessThan(content.indexOf('second thought')) + expect(content.indexOf('second thought')).toBeLessThan(content.indexOf('second answer')) + }) +}) diff --git a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx index d5951fdd807..1216c734100 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx @@ -81,6 +81,23 @@ describe('Answer Component', () => { expect(container.querySelector('.group')).toBeInTheDocument() }) + it('should render custom agent content when only agent response parts exist', () => { + render( +
streamed answer
} + />, + ) + + expect(screen.getByTestId('custom-agent-content')).toHaveTextContent('streamed answer') + }) + it('should render file lists', () => { render( String(item)) + isArray = true + } + } + catch { + } + + return toolNames + .filter(Boolean) + .map((name, index) => ({ + name, + label: thought.tool_labels?.toolName?.en_US ?? name, + input: readIndexedValue(thought.tool_input, isArray, index), + output: readIndexedValue(thought.observation, isArray, index), + isFinished: !!thought.observation || !responding, + })) +} + +function formatDuration(seconds: number, t: ReturnType>['t']) { + const safeSeconds = Math.max(0, seconds) + if (safeSeconds < 60) + return t('agentDetail.configure.answer.duration.second', { count: Number(safeSeconds.toFixed(2)) }) + + const minutes = Math.floor(safeSeconds / 60) + const remainingSeconds = Math.floor(safeSeconds % 60) + + return [ + t('agentDetail.configure.answer.duration.minute', { count: minutes }), + t('agentDetail.configure.answer.duration.second', { count: remainingSeconds }), + ].join(' ') +} + +function formatLatencyDuration(latency: NonNullable['latency'], t: ReturnType>['t']) { + const numericLatency = Number(latency) + if (!Number.isNaN(numericLatency)) + return formatDuration(numericLatency, t) + + return String(latency) +} + +function getCompletedTitle(latency: NonNullable['latency'] | undefined, t: ReturnType>['t']) { + const numericLatency = Number(latency) + if (latency != null && !Number.isNaN(numericLatency) && numericLatency > 0) { + return t('agentDetail.configure.answer.workedFor', { + duration: formatLatencyDuration(latency, t), + }) + } + + return t('agentDetail.configure.answer.workFinished') +} + +function hasThoughtAnswer(thought: ThoughtItem) { + return !!thought.answer?.trim() +} + +function useWorkingDuration(enabled?: boolean) { + const startedAtRef = useRef(null) + const [now, setNow] = useState(0) + + useEffect(() => { + if (!enabled) + return + + startedAtRef.current = Date.now() + const timer = window.setInterval(() => { + setNow(Date.now()) + }, 1000) + + return () => window.clearInterval(timer) + }, [enabled]) + + const elapsedSeconds = enabled && startedAtRef.current !== null + ? Math.max(0, Math.floor((now - startedAtRef.current) / 1000)) + : 0 + + return elapsedSeconds +} + +function ProcessShell({ + children, + collapsed, + icon, + title, + defaultOpen = false, +}: { + children?: ReactNode + collapsed?: boolean + icon: ReactNode + title: ReactNode + defaultOpen?: boolean +}) { + const [open, setOpen] = useState(defaultOpen) + const canExpand = !!children + const expanded = canExpand && open && !collapsed + + return ( +
+ + {expanded && ( +
+
+
+ {children} +
+
+ )} +
+ ) +} + +function ThoughtProcess({ + thought, + defaultOpen, +}: { + thought: ThoughtItem + defaultOpen?: boolean +}) { + const { t } = useTranslation() + const summary = thought.thought.trim() || t('chat.thought', { ns: 'common' }) + + return ( + } + title={defaultOpen ? t('chat.thought', { ns: 'common' }) : summary} + defaultOpen={defaultOpen} + > + + + ) +} + +function ToolProcessCard({ + tool, +}: { + tool: ToolProcess +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + return ( +
+ + {open && ( +
+
+
+ {t('thought.requestTitle', { ns: 'tools' })} +
+
{tool.input}
+
+
+
+ {t('thought.responseTitle', { ns: 'tools' })} +
+
{tool.output}
+
+
+ )} +
+ ) +} + +function AgentThoughtsProcessList({ + item, + responding, +}: { + item: ChatItem + responding?: boolean +}) { + return ( +
+ {item.agent_thoughts?.map((thought, index) => ( + + ))} +
+ ) +} + +function AgentThoughtProcessItem({ + thought, + responding, + defaultOpen, +}: { + thought: ThoughtItem + responding?: boolean + defaultOpen?: boolean +}) { + const tools = getToolProcesses(thought, responding) + const answer = thought.answer?.trim() + + return ( +
+ {answer && ( +
+ +
+ )} + {!answer && thought.thought && ( + + )} + {tools.map(tool => ( + + ))} + {!!thought.message_files?.length && ( + + )} +
+ ) +} + +function AgentResponsePartList({ + item, + responding, +}: { + item: ChatItem + responding?: boolean +}) { + return ( +
+ {item.agent_response_parts?.map((part, index) => { + if (part.type === 'message') { + if (!part.content) + return null + + return ( +
+ +
+ ) + } + + return ( + + ) + })} +
+ ) +} + +function AgentThoughtsProcessGroup({ + item, + responding, +}: { + item: ChatItem + responding?: boolean +}) { + const { t } = useTranslation('agentV2') + const [open, setOpen] = useState(false) + const workingDuration = formatDuration(useWorkingDuration(responding), t) + const completedTitle = getCompletedTitle(item.more?.latency, t) + + if (responding) { + return ( +
+
+ + {t('agentDetail.configure.answer.workingFor', { duration: workingDuration })} + +
+ +
+ ) + } + + return ( +
+ + {open && ( + + )} +
+ ) +} + +export function AgentRosterResponseContent({ + item, + responding, + content, +}: AgentRosterResponseContentProps) { + const { + annotation, + agent_thoughts, + } = item + const hasAgentThoughtAnswer = agent_thoughts?.some(hasThoughtAnswer) + + if (annotation?.logAnnotation) { + return ( + + ) + } + + return ( +
+ {!!item.agent_response_parts?.length && ( + + )} + {!item.agent_response_parts?.length && ( + <> + {!!agent_thoughts?.length && hasAgentThoughtAnswer && ( + + )} + {!!agent_thoughts?.length && !hasAgentThoughtAnswer && ( + + )} + {content && ( +
+ +
+ )} + + )} +
+ ) +} diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index e46f80369a3..850506bcf29 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -42,6 +42,11 @@ type AnswerProps = { noChatInput?: boolean switchSibling?: (siblingMessageId: string) => void hideAvatar?: boolean + renderAgentContent?: (props: { + item: ChatItem + responding?: boolean + content?: string + }) => ReactNode onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise } const Answer: FC = ({ @@ -58,6 +63,7 @@ const Answer: FC = ({ noChatInput, switchSibling, hideAvatar, + renderAgentContent, onHumanInputFormSubmit, }) => { const { t } = useTranslation() @@ -74,6 +80,8 @@ const Answer: FC = ({ humanInputFilledFormDataList, } = item const hasAgentThoughts = !!agent_thoughts?.length + const hasAgentResponseParts = !!item.agent_response_parts?.length + const hasAgentContent = hasAgentThoughts || hasAgentResponseParts const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length // Truthy only when there is real reasoning text. Rehydrated messages carry an empty // `{}` (the field is always persisted), and `!!{}` would otherwise be truthy. @@ -144,6 +152,15 @@ const Answer: FC = ({ }, [switchSibling, item.prevSibling, item.nextSibling]) const contentIsEmpty = typeof content === 'string' && content.trim() === '' + const agentContentNode = renderAgentContent + ? renderAgentContent({ item, responding, content }) + : ( + + ) // Reasoning is "done" — freeze the elapsed timer and collapse the panel — as soon as ANY of: // ① the answer has begun streaming (first text delta): the only signal that fires // mid-node, so it drives the normal think→answer handoff; @@ -175,7 +192,7 @@ const Answer: FC = ({ className={cn('relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 body-lg-regular text-text-primary')} > { - !responding && contentIsEmpty && !hasAgentThoughts && ( + !responding && contentIsEmpty && !hasAgentContent && ( = ({ && item.siblingCount > 1 && !responding && contentIsEmpty - && !hasAgentThoughts + && !hasAgentContent && ( = ({ )} {/* Block 2: Response Content (when human inputs exist) */} - {hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts || hasReasoning) && ( + {hasHumanInputs && (responding || !contentIsEmpty || hasAgentContent || hasReasoning) && (
= ({ ) } { - responding && contentIsEmpty && !hasAgentThoughts && !hasReasoning && ( + responding && contentIsEmpty && !hasAgentContent && !hasReasoning && (
) } { - !contentIsEmpty && !hasAgentThoughts && ( + !contentIsEmpty && !hasAgentContent && ( ) } { - hasAgentThoughts && ( - + hasAgentContent && ( + agentContentNode ) } { @@ -380,24 +393,20 @@ const Answer: FC = ({ ) } { - responding && contentIsEmpty && !hasAgentThoughts && !hasReasoning && ( + responding && contentIsEmpty && !hasAgentContent && !hasReasoning && (
) } { - !contentIsEmpty && !hasAgentThoughts && ( + !contentIsEmpty && !hasAgentContent && ( ) } { - hasAgentThoughts && ( - + hasAgentContent && ( + agentContentNode ) } { diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index 8f3119b32d6..e79368a61bd 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -49,6 +49,7 @@ type ChatInputAreaProps = { sendButtonLoading?: boolean footerNotice?: ReactNode footerNoticeTooltip?: ReactNode + autoFocus?: boolean /** * Controls whether pressing Enter sends the message. * - true (default): Enter sends, Shift+Enter inserts newline @@ -57,7 +58,7 @@ type ChatInputAreaProps = { */ sendOnEnter?: boolean } -const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendButtonLabel, sendButtonLoading, footerNotice, footerNoticeTooltip, sendOnEnter = true }: ChatInputAreaProps) => { +const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendButtonLabel, sendButtonLoading, footerNotice, footerNoticeTooltip, autoFocus = true, sendOnEnter = true }: ChatInputAreaProps) => { const { t } = useTranslation() const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight() const [query, setQuery] = useState('') @@ -187,7 +188,7 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s placeholder={!readonly && customPlaceholder?.trim() ? customPlaceholder : decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')} // Existing chat behavior focuses the composer as soon as it opens. // eslint-disable-next-line jsx-a11y/no-autofocus - autoFocus + autoFocus={autoFocus} minRows={1} value={query} onChange={e => handleQueryChange(e.target.value)} diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index df1a1619042..612f977d38b 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -4,7 +4,7 @@ import type { ChatItemInTree, Inputs, } from '../types' -import type { InputForm } from './type' +import type { InputForm, ThoughtItem } from './type' import type AudioPlayer from '@/app/components/base/audio-btn/audio' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { Annotation } from '@/models/log' @@ -12,7 +12,8 @@ import type { IOnDataMoreInfo, IOtherOptions, } from '@/service/base' -import type { ReasoningChunkResponse } from '@/types/workflow' +import type { VisionFile } from '@/types/app' +import type { FileResponse, ReasoningChunkResponse } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { uniqBy } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' @@ -33,6 +34,7 @@ import { getProcessedFilesFromResponse, } from '@/app/components/base/file-uploader/utils' import { isInstalledAppPath } from '@/app/components/explore/installed-app/routes' +import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils' import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' import useTimestamp from '@/hooks/use-timestamp' import { useParams, usePathname } from '@/next/navigation' @@ -48,9 +50,35 @@ import { } from './utils' type GetAbortController = (abortController: AbortController) => void +type HistoryMessageFile = Partial & { + id?: string + belongs_to?: string +} +type HistoryConversationMessage = { + id: string + answer?: string + message?: { role: string, text: string, files?: FileEntity[] }[] + message_files?: HistoryMessageFile[] + agent_thoughts?: ThoughtItem[] | null + retriever_resources?: ChatItem['citation'] + metadata?: { + reasoning?: ChatItem['reasoningContent'] + } + created_at?: number + answer_tokens?: number + message_tokens?: number + provider_response_latency?: number + workflow_run_id?: string + feedback?: ChatItem['feedback'] + inputs?: unknown + query?: string +} +type ConversationMessagesResponse = { + data: HistoryConversationMessage[] +} type SendCallback = { - onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise - onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise + onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise + onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise onConversationComplete?: (conversationId: string, workflowRunId?: string) => void onUnhandledEvent?: IOtherOptions['onUnhandledEvent'] onSendSettled?: (hasError?: boolean) => void @@ -58,9 +86,107 @@ type SendCallback = { } type UseChatOptions = { + isNewAgent?: boolean timezone?: string } +function mergeStreamingThought(currentThought: ThoughtItem, nextThought: ThoughtItem): ThoughtItem { + return { + ...nextThought, + message_files: nextThought.message_files?.length ? nextThought.message_files : currentThought.message_files, + } +} + +function appendAgentResponseMessagePart(responseItem: ChatItemInTree, message: string) { + if (!responseItem.agent_response_parts) + responseItem.agent_response_parts = [] + + const lastPart = responseItem.agent_response_parts.at(-1) + if (lastPart?.type === 'message') { + lastPart.content += message + } + else { + responseItem.agent_response_parts.push({ + type: 'message', + content: message, + }) + } +} + +function upsertAgentResponseThoughtPart(responseItem: ChatItemInTree, thought: ThoughtItem) { + if (!responseItem.agent_response_parts) + responseItem.agent_response_parts = [] + + const partIndex = responseItem.agent_response_parts.findIndex(part => + part.type === 'thought' && part.thought.id === thought.id, + ) + if (partIndex > -1) { + responseItem.agent_response_parts[partIndex] = { + type: 'thought', + thought, + } + return + } + + responseItem.agent_response_parts.push({ + type: 'thought', + thought, + }) +} + +function getHistoryAgentThoughts(responseItem: HistoryConversationMessage) { + if (!Array.isArray(responseItem.agent_thoughts)) + return [] + + const messageFiles: VisionFile[] = responseItem.message_files?.map(file => ({ + id: file.id, + type: file.type || '', + transfer_method: file.transfer_method || TransferMethod.remote_url, + url: file.url || '', + upload_file_id: file.upload_file_id || '', + belongs_to: file.belongs_to, + })) || [] + + return addFileInfos(sortAgentSorts(responseItem.agent_thoughts), messageFiles) +} + +function toHistoryFileResponse(file: HistoryMessageFile): FileResponse { + return { + related_id: file.related_id || file.id || '', + extension: file.extension || '', + filename: file.filename || '', + size: file.size || 0, + mime_type: file.mime_type || file.type || '', + transfer_method: file.transfer_method || TransferMethod.remote_url, + type: file.type || '', + url: file.url || '', + upload_file_id: file.upload_file_id || '', + remote_url: file.remote_url || '', + } +} + +function getHistoryAnswerFiles(responseItem: HistoryConversationMessage) { + const answerFiles = responseItem.message_files?.filter(file => file.belongs_to === 'assistant') || [] + + return getProcessedFilesFromResponse(answerFiles.map(file => toHistoryFileResponse({ + ...file, + related_id: file.related_id || file.id || '', + upload_file_id: file.upload_file_id || '', + }))) +} + +function isHistoryConversationMessage(value: unknown): value is HistoryConversationMessage { + return typeof value === 'object' && value !== null && typeof (value as { id?: unknown }).id === 'string' +} + +function getConversationMessagesData(response: unknown): ConversationMessagesResponse['data'] { + if (typeof response !== 'object' || response === null) + return [] + + const data = (response as { data?: unknown }).data + return Array.isArray(data) ? data.filter(isHistoryConversationMessage) : [] +} + export const useChat = ( config?: ChatConfig, formSettings?: { @@ -279,14 +405,18 @@ export const useChat = ( getAbortController: (abortController) => { workflowEventsAbortControllerRef.current = abortController }, - onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: IOnDataMoreInfo) => { + onData: (message: string, isFirstMessage: boolean, { event, conversationId: newConversationId, messageId, taskId }: IOnDataMoreInfo) => { updateChatTreeNode(messageId, (responseItem) => { - const isAgentMode = responseItem.agent_thoughts && responseItem.agent_thoughts.length > 0 - if (!isAgentMode) { + const agentThoughts = responseItem.agent_thoughts ?? [] + const isNewAgentMessage = options.isNewAgent && (event === 'agent_message' || event === 'message') + if (isNewAgentMessage) { + appendAgentResponseMessagePart(responseItem, message) + } + else if (!agentThoughts.length || options.isNewAgent) { responseItem.content = responseItem.content + message } else { - const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1] + const lastThought = agentThoughts[agentThoughts.length - 1] if (lastThought) lastThought.thought = lastThought.thought + message } @@ -388,14 +518,16 @@ export const useChat = ( else { const lastThought = responseItem.agent_thoughts.at(-1) if (lastThought?.id === thought.id) { - thought.thought = lastThought.thought - thought.message_files = lastThought.message_files - responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = thought + responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = mergeStreamingThought(lastThought, thought) } else { responseItem.agent_thoughts.push(thought) } } + if (options.isNewAgent) { + const currentThought = responseItem.agent_thoughts.find(item => item.id === thought.id) ?? thought + upsertAgentResponseThoughtPart(responseItem, currentThought) + } }) }, onMessageEnd: (messageEnd) => { @@ -637,7 +769,7 @@ export const useChat = ( {}, otherOptions, ) - }, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer]) + }, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer, options.isNewAgent]) const updateCurrentQAOnTree = useCallback(({ parentId, @@ -780,12 +912,16 @@ export const useChat = ( getAbortController: (abortController) => { workflowEventsAbortControllerRef.current = abortController }, - onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => { - if (!isAgentMode) { + onData: (message: string, isFirstMessage: boolean, { event, conversationId: newConversationId, messageId, taskId }: any) => { + const isNewAgentMessage = options.isNewAgent && (event === 'agent_message' || event === 'message') + if (isNewAgentMessage) { + appendAgentResponseMessagePart(responseItem, message) + } + else if (!isAgentMode || options.isNewAgent) { responseItem.content = responseItem.content + message } else { - const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1] + const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts.length - 1] if (lastThought) lastThought.thought = lastThought.thought + message // need immer setAutoFreeze } @@ -837,35 +973,55 @@ export const useChat = ( let completedWorkflowRunId = responseItem.workflow_run_id if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) { - const { data }: any = await onGetConversationMessages( + const conversationMessagesResponse = await onGetConversationMessages( conversationIdRef.current, newAbortController => conversationMessagesAbortControllerRef.current = newAbortController, ) - const newResponseItem = data.find((item: any) => item.id === responseItem.id) + const data = getConversationMessagesData(conversationMessagesResponse) + const newResponseItem = data.find(item => item.id === responseItem.id) completedWorkflowRunId = newResponseItem?.workflow_run_id ?? completedWorkflowRunId if (!newResponseItem) return onConversationComplete?.(conversationIdRef.current, completedWorkflowRunId) - const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer + const historyAgentThoughts = getHistoryAgentThoughts(newResponseItem) + const hasHistoryAgentThoughtAnswer = historyAgentThoughts.some(thought => thought.answer?.trim()) + const lastHistoryAgentThought = historyAgentThoughts.at(-1) + const historyAnswer = newResponseItem.answer || '' + const isUseAgentThought = (lastHistoryAgentThought?.thought === historyAnswer) || (options.isNewAgent && hasHistoryAgentThoughtAnswer) + const messageLog = Array.isArray(newResponseItem.message) ? newResponseItem.message : [] + const answerTokens = newResponseItem.answer_tokens ?? 0 + const messageTokens = newResponseItem.message_tokens ?? 0 + const providerResponseLatency = newResponseItem.provider_response_latency ?? 0 + const historyAnswerFiles = getHistoryAnswerFiles(newResponseItem) updateChatTreeNode(responseItem.id, { - content: isUseAgentThought ? '' : newResponseItem.answer, + content: isUseAgentThought ? '' : historyAnswer, + agent_thoughts: historyAgentThoughts, + agent_response_parts: undefined, + citation: newResponseItem.retriever_resources, + reasoningContent: newResponseItem.metadata?.reasoning, + reasoningFinished: true, + message_files: historyAnswerFiles, + allFiles: undefined, + workflowProcess: undefined, + workflow_run_id: newResponseItem.workflow_run_id ?? completedWorkflowRunId, + feedback: newResponseItem.feedback, log: [ - ...newResponseItem.message, - ...(newResponseItem.message.at(-1).role !== 'assistant' + ...messageLog, + ...(messageLog.at(-1)?.role !== 'assistant' ? [ { role: 'assistant', - text: newResponseItem.answer, - files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], + text: historyAnswer, + files: historyAnswerFiles, }, ] : []), ], 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, + time: formatTime(newResponseItem.created_at ?? Date.now(), 'hh:mm A'), + tokens: answerTokens + messageTokens, + latency: providerResponseLatency.toFixed(2), + tokens_per_second: providerResponseLatency > 0 ? (answerTokens / providerResponseLatency).toFixed(2) : undefined, }, // for agent log conversationId: conversationIdRef.current, @@ -953,14 +1109,16 @@ export const useChat = ( const lastThought = response.agent_thoughts.at(-1) // thought changed but still the same thought, so update. if (lastThought.id === thought.id) { - thought.thought = lastThought.thought - thought.message_files = lastThought.message_files - responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought + responseItem.agent_thoughts![response.agent_thoughts.length - 1] = mergeStreamingThought(lastThought, thought) } else { responseItem.agent_thoughts!.push(thought) } } + if (options.isNewAgent) { + const currentThought = responseItem.agent_thoughts?.find(item => item.id === thought.id) ?? thought + upsertAgentResponseThoughtPart(responseItem, currentThought) + } updateCurrentQAOnTree({ placeholderQuestionId, questionItem, @@ -1283,6 +1441,7 @@ export const useChat = ( formatTime, createAudioPlayerManager, formSettings, + options.isNewAgent, ]) const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx index 733abcae322..7cc861e6719 100644 --- a/web/app/components/base/chat/chat/index.tsx +++ b/web/app/components/base/chat/chat/index.tsx @@ -78,6 +78,11 @@ export type ChatProps = { sidebarCollapseState?: boolean hideAvatar?: boolean sendOnEnter?: boolean + renderAgentContent?: (props: { + item: ChatItem + responding?: boolean + content?: string + }) => ReactNode onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise getHumanInputNodeData?: (nodeID: string) => Node | undefined } @@ -130,6 +135,7 @@ const Chat: FC = ({ sidebarCollapseState, hideAvatar, sendOnEnter, + renderAgentContent, onHumanInputFormSubmit, getHumanInputNodeData, }) => { @@ -204,6 +210,7 @@ const Chat: FC = ({ noChatInput={noChatInput} switchSibling={switchSibling} hideAvatar={hideAvatar} + renderAgentContent={renderAgentContent} onHumanInputFormSubmit={onHumanInputFormSubmit} /> ) diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts index 167a6a3548b..4c0c80f975b 100644 --- a/web/app/components/base/chat/chat/type.ts +++ b/web/app/components/base/chat/chat/type.ts @@ -41,6 +41,7 @@ export type ThoughtItem = { id: string tool: string // plugin or dataset. May has multi. thought: string + answer?: string tool_input: string tool_labels?: { [key: string]: TypeWithI18N } message_id: string @@ -51,6 +52,16 @@ export type ThoughtItem = { message_files?: FileEntity[] } +type AgentResponsePart + = | { + type: 'thought' + thought: ThoughtItem + } + | { + type: 'message' + content: string + } + export type CitationItem = { content: string data_source_type: string @@ -113,6 +124,7 @@ export type IChatItem = { suggestedQuestions?: string[] log?: { role: string, text: string, files?: FileEntity[] }[] agent_thoughts?: ThoughtItem[] + agent_response_parts?: AgentResponsePart[] // for LLM reasoning (chain-of-thought) in "separated" mode, keyed by LLM node id reasoningContent?: Record reasoningFinished?: boolean diff --git a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx index 3d6766b0024..7146fc09332 100644 --- a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx @@ -8,6 +8,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { $getRoot, + $isElementNode, BLUR_COMMAND, COMMAND_PRIORITY_EDITOR, createCommand, @@ -245,7 +246,7 @@ describe('PromptEditorContent', () => { }) }) - it('should infer file type when rendering a file-name output token', async () => { + it('should render file-name output tokens with the declared output type', async () => { const captures: Captures = { editor: null, eventEmitter: null } render( @@ -266,7 +267,7 @@ describe('PromptEditorContent', () => { await waitFor(() => { expect(screen.getByText('qna_report.pdf')).toBeInTheDocument() - expect(screen.getByText('file')).toBeInTheDocument() + expect(screen.getByText('string')).toBeInTheDocument() }) }) @@ -317,6 +318,95 @@ describe('PromptEditorContent', () => { expect(screen.queryByText('string')).not.toBeInTheDocument() }) + it('should preserve committed output name selection state when refreshing output block props', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const initialOnChange = vi.fn() + const nextOnChange = vi.fn() + const nextOutputs = [{ name: 'summary', type: 'string' as const }] + + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(captures.editor).not.toBeNull() + expect(screen.getByText('output')).toBeInTheDocument() + }) + + act(() => { + captures.editor?.update(() => { + const visitNode = (node: Parameters[0]) => { + if (!$isElementNode(node)) + return + + node.getChildren().forEach((child) => { + if (child instanceof AgentOutputBlockNode) { + child.setOutput('summary', 'string', true, nextOutputs, initialOnChange, undefined, false, true) + return + } + + visitNode(child) + }) + } + + visitNode($getRoot()) + }) + }) + + rerender( + , + ) + + await waitFor(() => { + captures.editor!.getEditorState().read(() => { + const root = $getRoot() + + const findOutputNode = (node: Parameters[0]): AgentOutputBlockNode | null => { + if (!$isElementNode(node)) + return null + + for (const child of node.getChildren()) { + if (child instanceof AgentOutputBlockNode) + return child + + const outputNode = findOutputNode(child) + if (outputNode) + return outputNode + } + + return null + } + + const outputNode = findOutputNode(root) + expect(outputNode?.shouldSelectNameOnEdit()).toBe(false) + expect(outputNode?.shouldOpenTypeSelectOnEdit()).toBe(true) + expect(outputNode?.getOnChange()).toBe(nextOnChange) + }) + }) + }) + it('should render optional blocks and open shortcut popups with the real editor runtime', async () => { const captures: Captures = { editor: null, eventEmitter: null } const onEditorChange = vi.fn() diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index ebd6968dd69..b5f493c6052 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -110,7 +110,7 @@ const EditableSyncPlugin: FC<{ editable: boolean }> = ({ editable }) => { return null } -type PromptEditorAriaProps = Pick +type PromptEditorAriaProps = Pick export type PromptEditorProps = PromptEditorAriaProps & { instanceId?: string @@ -150,6 +150,8 @@ export type PromptEditorProps = PromptEditorAriaProps & { } const PromptEditor: FC = ({ + 'aria-controls': ariaControls, + 'aria-haspopup': ariaHasPopup, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, instanceId, @@ -252,6 +254,8 @@ const PromptEditor: FC = ({
({ +const { mockEditorFocus, mockEditorUpdate, mockGetRootText, mockSelectNext, mockSetOutput } = vi.hoisted(() => ({ mockEditorFocus: vi.fn(), mockEditorUpdate: vi.fn((callback: () => void) => callback()), mockGetRootText: { value: '[§output:summary:summary§]', }, - mockNodeReplace: vi.fn(), mockSelectNext: vi.fn(), + mockSetOutput: vi.fn(), })) vi.mock('@lexical/react/LexicalComposerContext') @@ -22,11 +21,14 @@ vi.mock('@langgenius/dify-ui/select', () => ({ Select: ({ children, onValueChange, + open, }: { children: ReactNode onValueChange: (value: string) => void + open?: boolean }) => (
+ {open ? 'open' : 'closed'} {children}
@@ -62,11 +64,6 @@ vi.mock('lexical', async (importOriginal) => { }) vi.mock('../node', () => ({ - $createAgentOutputBlockNode: vi.fn((name: string, outputType: string, isEditing: boolean) => ({ - isEditing, - name, - outputType, - })), $isAgentOutputBlockNode: () => true, })) @@ -90,13 +87,65 @@ describe('AgentOutputBlockComponent', () => { {}, ] as unknown as ReturnType) vi.mocked($getNodeByKey).mockReturnValue({ - replace: mockNodeReplace.mockReturnValue({ - selectNext: mockSelectNext, - }), + selectNext: mockSelectNext, + setOutput: mockSetOutput, } as never) }) - it('does not replace the Lexical node while typing an output name and commits on blur', async () => { + it('focuses and selects the output name while editing a newly inserted output block', () => { + render( + , + ) + + const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) as HTMLInputElement + + expect(input).toHaveFocus() + expect(input.selectionStart).toBe(0) + expect(input.selectionEnd).toBe('output'.length) + }) + + it('focuses without selecting the output name after an edited output name is committed', () => { + render( + , + ) + + const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) as HTMLInputElement + + expect(input).toHaveFocus() + expect(input.selectionStart).toBe('summary'.length) + expect(input.selectionEnd).toBe('summary'.length) + }) + + it('keeps the type select open after the edited output block is remounted', () => { + render( + , + ) + + expect(screen.getByTestId('type-select-state')).toHaveTextContent('open') + }) + + it('does not update the Lexical node while typing an output name and commits on blur', async () => { const user = userEvent.setup() const onChange = vi.fn() @@ -117,12 +166,12 @@ describe('AgentOutputBlockComponent', () => { await user.type(input, 'summary') expect(input).toHaveValue('summary') - expect(mockNodeReplace).not.toHaveBeenCalled() + expect(mockSetOutput).not.toHaveBeenCalled() expect(onChange).not.toHaveBeenCalled() - await user.tab() + fireEvent.blur(input) - expect($createAgentOutputBlockNode).toHaveBeenCalledWith( + expect(mockSetOutput).toHaveBeenCalledWith( 'summary', 'string', false, @@ -135,9 +184,10 @@ describe('AgentOutputBlockComponent', () => { ]), onChange, undefined, + false, + false, ) - expect(mockNodeReplace).toHaveBeenCalledTimes(1) - expect(mockSelectNext).not.toHaveBeenCalled() + expect(mockSetOutput).toHaveBeenCalledTimes(1) expect(mockEditorFocus).not.toHaveBeenCalled() expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ expect.objectContaining({ @@ -148,8 +198,10 @@ describe('AgentOutputBlockComponent', () => { ]), '[§output:summary:summary§]') }) - it('moves the editor selection after the output block when committing with Enter', async () => { + it('syncs the output name and moves the editor selection after the output block when committing with Enter', async () => { const user = userEvent.setup() + const onChange = vi.fn() + mockGetRootText.value = 'Generate [§output:output:output§]' render( { outputType="string" isEditing outputs={outputs} + onChange={onChange} />, ) @@ -167,13 +220,82 @@ describe('AgentOutputBlockComponent', () => { await user.type(input, 'summary') await user.keyboard('{Enter}') - expect(mockNodeReplace).toHaveBeenCalledTimes(1) + expect(mockSetOutput).toHaveBeenCalledWith( + 'summary', + 'string', + false, + expect.arrayContaining([ + expect.objectContaining({ + name: 'summary', + type: 'string', + }), + ]), + onChange, + undefined, + false, + false, + ) expect(mockSelectNext).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('type-select-state')).toHaveTextContent('closed') + expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ + name: 'summary', + }), + ]), 'Generate [§output:summary:summary§]') await waitFor(() => { expect(mockEditorFocus).toHaveBeenCalledTimes(1) }) }) + it('syncs the output name and opens the type select when committing with Tab', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + mockGetRootText.value = 'Generate [§output:output:output§]' + + render( + , + ) + + const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) + + await user.clear(input) + await user.type(input, 'summary') + await user.keyboard('{Tab}') + + expect(mockSetOutput).toHaveBeenCalledWith( + 'summary', + 'string', + true, + expect.arrayContaining([ + expect.objectContaining({ + name: 'summary', + type: 'string', + }), + ]), + onChange, + undefined, + false, + true, + ) + expect(mockSelectNext).not.toHaveBeenCalled() + expect(screen.getByTestId('type-select-state')).toHaveTextContent('open') + expect(input).not.toHaveFocus() + expect((input as HTMLInputElement).selectionStart).toBe('summary'.length) + expect((input as HTMLInputElement).selectionEnd).toBe('summary'.length) + expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ + name: 'summary', + }), + ]), 'Generate [§output:summary:summary§]') + }) + it('commits the input DOM value on Enter even before React state rerenders', () => { const onChange = vi.fn() @@ -193,7 +315,7 @@ describe('AgentOutputBlockComponent', () => { fireEvent.change(input, { target: { value: 'summary' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect($createAgentOutputBlockNode).toHaveBeenCalledWith( + expect(mockSetOutput).toHaveBeenCalledWith( 'summary', 'string', false, @@ -204,12 +326,14 @@ describe('AgentOutputBlockComponent', () => { ]), onChange, undefined, + false, + false, ) + expect(mockSelectNext).toHaveBeenCalledTimes(1) }) - it('automatically commits file-name outputs as file type', () => { + it('does not commit output names containing dots', () => { const onChange = vi.fn() - mockGetRootText.value = '[§output:qna_report.pdf:qna_report.pdf§]' render( { fireEvent.change(input, { target: { value: 'qna_report.pdf' } }) fireEvent.keyDown(input, { key: 'Enter' }) - expect($createAgentOutputBlockNode).toHaveBeenCalledWith( - 'qna_report.pdf', - 'file', - false, - expect.arrayContaining([ - expect.objectContaining({ - name: 'qna_report.pdf', - type: 'file', - file: { - extensions: [], - mime_types: [], - }, - }), - ]), - onChange, - undefined, - ) - expect(onChange).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ - name: 'qna_report.pdf', - type: 'file', - }), - ]), '[§output:qna_report.pdf:qna_report.pdf§]') - }) - - it('keeps dotted output names as the selected type when the extension is not whitelisted', () => { - const onChange = vi.fn() - mockGetRootText.value = '[§output:report.customext:report.customext§]' - - render( - , - ) - - const input = screen.getByRole('textbox', { name: 'workflow.nodes.agent.outputVars.nameLabel' }) - - fireEvent.change(input, { target: { value: 'report.customext' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - - expect($createAgentOutputBlockNode).toHaveBeenCalledWith( - 'report.customext', - 'string', - false, - expect.arrayContaining([ - expect.objectContaining({ - name: 'report.customext', - type: 'string', - }), - ]), - onChange, - undefined, - ) + expect(mockSetOutput).not.toHaveBeenCalled() + expect(mockSelectNext).not.toHaveBeenCalled() + expect(onChange).not.toHaveBeenCalled() }) it('does not commit the name blur before selecting an output type', () => { @@ -313,7 +382,7 @@ describe('AgentOutputBlockComponent', () => { fireEvent.click(screen.getByRole('button', { name: 'Select file' })) - expect($createAgentOutputBlockNode).toHaveBeenCalledWith( + expect(mockSetOutput).toHaveBeenCalledWith( 'summary', 'file', false, @@ -325,6 +394,8 @@ describe('AgentOutputBlockComponent', () => { ]), onChange, undefined, + false, + false, ) }) @@ -350,7 +421,7 @@ describe('AgentOutputBlockComponent', () => { await user.type(input, 'summary') await user.keyboard('{Enter}') - expect(mockNodeReplace).not.toHaveBeenCalled() + expect(mockSetOutput).not.toHaveBeenCalled() expect(onChange).not.toHaveBeenCalled() expect(mockEditorFocus).not.toHaveBeenCalled() }) @@ -372,7 +443,7 @@ describe('AgentOutputBlockComponent', () => { expect(screen.getByText('file')).toBeInTheDocument() }) - it('requests the output editor when hovering a committed output block', () => { + it('requests the output editor when clicking a committed output block', () => { const onEdit = vi.fn() render( @@ -386,7 +457,14 @@ describe('AgentOutputBlockComponent', () => { />, ) - fireEvent.mouseEnter(screen.getByText('qna_report_pdf').parentElement!) + const outputBlock = screen.getByText('qna_report_pdf').parentElement! + + fireEvent.mouseEnter(outputBlock) + + expect(outputBlock).toHaveClass('cursor-pointer') + expect(onEdit).not.toHaveBeenCalled() + + fireEvent.click(outputBlock) expect(onEdit).toHaveBeenCalledWith('qna_report_pdf', 'file') }) diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx index 95f71afc67a..47e487eb463 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx @@ -12,7 +12,6 @@ import { import { extractAgentOutputNames, getAgentOutputToken, - inferAgentOutputType, parseAgentOutputToken, replaceAgentOutputName, } from '../utils' @@ -40,26 +39,16 @@ describe('AgentOutputBlockNode', () => { }) }) - it('should persist and parse file-name output tokens', () => { + it('should select the output name only for newly inserted editing nodes', () => { runInEditor(() => { - const node = $createAgentOutputBlockNode('qna_report.pdf', 'file') + const newEditingNode = $createAgentOutputBlockNode('output', 'string', true) + const existingEditingNode = $createAgentOutputBlockNode('summary', 'string', true, [], undefined, undefined, false) + const typeSelectingNode = $createAgentOutputBlockNode('summary', 'string', true, [], undefined, undefined, false, true) - expect(node.getTextContent()).toBe('[§output:qna_report.pdf:qna_report.pdf§]') - expect(getAgentOutputToken('qna_report.pdf')).toBe('[§output:qna_report.pdf:qna_report.pdf§]') + expect(newEditingNode.shouldSelectNameOnEdit()).toBe(true) + expect(existingEditingNode.shouldSelectNameOnEdit()).toBe(false) + expect(typeSelectingNode.shouldOpenTypeSelectOnEdit()).toBe(true) }) - - expect(parseAgentOutputToken('Use [§output:qna_report.pdf:qna_report.pdf§]')).toEqual({ - name: 'qna_report.pdf', - start: 4, - end: 44, - }) - }) - - it('should infer file type only for whitelisted file extensions', () => { - expect(inferAgentOutputType('qna_report.pdf', 'string')).toBe('file') - expect(inferAgentOutputType('QNA_REPORT.PDF', 'string')).toBe('file') - expect(inferAgentOutputType('report.customext', 'string')).toBe('string') - expect(inferAgentOutputType('report.customext', 'object')).toBe('object') }) it('should parse bracketed output tokens and legacy bare tokens', () => { @@ -77,9 +66,9 @@ describe('AgentOutputBlockNode', () => { }) it('should extract output names from bracketed and legacy tokens', () => { - expect([...extractAgentOutputNames('A [§output:summary:summary§] B §output:qna_report.pdf:qna_report.pdf§')]).toEqual([ + expect([...extractAgentOutputNames('A [§output:summary:summary§] B §output:final_summary:final_summary§')]).toEqual([ 'summary', - 'qna_report.pdf', + 'final_summary', ]) }) @@ -89,6 +78,11 @@ describe('AgentOutputBlockNode', () => { 'summary', 'final_summary', )).toBe('Use [§output:final_summary:final_summary§] and §output:other:other§') + expect(replaceAgentOutputName( + 'Generate [§output:output:output§] and §output:output:output§', + 'output', + 'summary', + )).toBe('Generate [§output:summary:summary§] and §output:summary:summary§') }) it('should create node with helper and support type guard checks', () => { diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx index 2305cf817c9..e27b05a781e 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/component.tsx @@ -11,15 +11,15 @@ import { } from '@langgenius/dify-ui/select' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { $getNodeByKey, $getRoot } from 'lexical' -import { memo, useRef, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { $createAgentOutputBlockNode, $isAgentOutputBlockNode } from './node' +import { $isAgentOutputBlockNode } from './node' import { AGENT_OUTPUT_NAME_PATTERN, AGENT_OUTPUT_TYPE_OPTIONS, createAgentOutputConfig, getAgentOutputTypeOption, - inferAgentOutputType, + replaceAgentOutputName, } from './utils' type AgentOutputBlockComponentProps = { @@ -27,6 +27,8 @@ type AgentOutputBlockComponentProps = { name: string outputType: AgentOutputTypeOptionValue isEditing: boolean + selectNameOnEdit?: boolean + openTypeSelectOnEdit?: boolean outputs: DeclaredOutputConfig[] onChange?: (outputs: DeclaredOutputConfig[], prompt?: string) => void onEdit?: (name: string, outputType: AgentOutputTypeOptionValue) => void @@ -42,13 +44,12 @@ function upsertOutput( if (!AGENT_OUTPUT_NAME_PATTERN.test(trimmedName)) return null - const nextOutputType = inferAgentOutputType(trimmedName, outputType) const existingIndex = outputs.findIndex(output => output.name === oldName) const duplicateIndex = outputs.findIndex(output => output.name === trimmedName && output.name !== oldName) if (duplicateIndex >= 0) return null - const nextOutput = createAgentOutputConfig(trimmedName, nextOutputType) + const nextOutput = createAgentOutputConfig(trimmedName, outputType) if (existingIndex >= 0) return outputs.map((output, index) => index === existingIndex ? nextOutput : output) @@ -60,6 +61,8 @@ const AgentOutputBlockComponent = ({ name, outputType, isEditing, + selectNameOnEdit = isEditing, + openTypeSelectOnEdit = false, outputs, onChange, onEdit, @@ -69,8 +72,30 @@ const AgentOutputBlockComponent = ({ const selected = getAgentOutputTypeOption(outputType) const [draftName, setDraftName] = useState(name) const [lastNodeName, setLastNodeName] = useState(name) + const [typeSelectOpen, setTypeSelectOpen] = useState(openTypeSelectOnEdit) + const nameInputRef = useRef(null) const skipNextBlurCommitRef = useRef(false) const latestDraftNameRef = useRef(name) + const skipNameFocusRef = useRef(false) + + useEffect(() => { + if (!isEditing) + return + if (skipNameFocusRef.current) { + skipNameFocusRef.current = false + return + } + + const input = nameInputRef.current + if (!input) + return + + input.focus() + if (selectNameOnEdit) + input.setSelectionRange(0, input.value.length) + else + input.setSelectionRange(input.value.length, input.value.length) + }, [isEditing, selectNameOnEdit]) if (name !== lastNodeName) { setLastNodeName(name) @@ -78,7 +103,20 @@ const AgentOutputBlockComponent = ({ latestDraftNameRef.current = name } - const commitOutput = (nextName: string, nextType: AgentOutputTypeOptionValue, selectAfterCommit = false) => { + const commitOutput = ( + nextName: string, + nextType: AgentOutputTypeOptionValue, + options: { + keepEditing?: boolean + openTypeSelectOnEdit?: boolean + selectAfterCommit?: boolean + } = {}, + ) => { + const { + keepEditing = false, + openTypeSelectOnEdit = false, + selectAfterCommit = false, + } = options const trimmedName = nextName.trim() const nextOutputs = upsertOutput(outputs, name, trimmedName, nextType) if (!nextOutputs) { @@ -93,11 +131,11 @@ const AgentOutputBlockComponent = ({ if (!$isAgentOutputBlockNode(node)) return - const nextOutputType = inferAgentOutputType(trimmedName, nextType) - const nextNode = node.replace($createAgentOutputBlockNode(trimmedName, nextOutputType, false, nextOutputs, onChange, onEdit)) + const currentPrompt = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') + node.setOutput(trimmedName, nextType, keepEditing, nextOutputs, onChange, onEdit, false, openTypeSelectOnEdit) if (selectAfterCommit) - nextNode.selectNext() - nextPrompt = $getRoot().getChildren().map(node => node.getTextContent()).join('\n') + node.selectNext() + nextPrompt = replaceAgentOutputName(currentPrompt, name, trimmedName) didCommit = true }) @@ -105,16 +143,44 @@ const AgentOutputBlockComponent = ({ return false onChange?.(nextOutputs, nextPrompt) + setDraftName(trimmedName) + latestDraftNameRef.current = trimmedName return true } + const handleTypeSelectOpenChange = (open: boolean) => { + setTypeSelectOpen(open) + if (!open) { + skipNextBlurCommitRef.current = false + editor.update(() => { + const node = $getNodeByKey(nodeKey) + if ($isAgentOutputBlockNode(node)) + node.setOpenTypeSelectOnEdit(false) + }) + } + } + + const handleEditRequest = () => { + onEdit?.(name, outputType) + } + if (!isEditing) { return ( onEdit?.(name, outputType)} + className="group/agent-output inline-flex min-w-[18px] cursor-pointer items-center gap-1 rounded-[5px] border border-util-colors-violet-violet-100 bg-util-colors-violet-violet-50 px-1 py-0.5 align-middle shadow-xs" + onClick={handleEditRequest} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') + return + + event.preventDefault() + handleEditRequest() + }} >