diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index 32c134b9fe3..4c9cb2d0db3 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -28,9 +28,9 @@ from libs.login import login_required from models.model import App, AppMode from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.entities.agent_entities import ComposerSavePayload +from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload -register_schema_models(console_ns, ComposerSavePayload) +register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload) register_response_schema_models( console_ns, AgentAppComposerResponse, @@ -91,6 +91,38 @@ class WorkflowAgentComposerApi(Resource): ) +@console_ns.route("/apps//workflows/draft/nodes//agent-composer/copy-from-roster") +class WorkflowAgentComposerCopyFromRosterApi(Resource): + @console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__]) + @console_ns.response( + 200, + "Workflow roster agent copied to inline agent", + console_ns.models[WorkflowAgentComposerResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]) + @with_current_user_id + @with_current_tenant_id + def post(self, tenant_id: str, account_id: str, app_model: App, node_id: str): + payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {}) + return dump_response( + WorkflowAgentComposerResponse, + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id=tenant_id, + app_id=app_model.id, + node_id=node_id, + account_id=account_id, + source_agent_id=payload.source_agent_id, + source_snapshot_id=payload.source_snapshot_id, + idempotency_key=payload.idempotency_key, + ), + ) + + @console_ns.route("/apps//workflows/draft/nodes//agent-composer/validate") class WorkflowAgentComposerValidateApi(Resource): @console_ns.expect(console_ns.models[ComposerSavePayload.__name__]) @@ -104,7 +136,7 @@ class WorkflowAgentComposerValidateApi(Resource): @with_current_tenant_id def post(self, tenant_id: str, app_model: App, node_id: str): payload = ComposerSavePayload.model_validate(console_ns.payload or {}) - ComposerConfigValidator.validate_save_payload(payload) + ComposerConfigValidator.validate_publish_payload(payload) AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) findings = AgentComposerService.collect_validation_findings( tenant_id=tenant_id, @@ -239,7 +271,7 @@ class AgentComposerValidateApi(Resource): def post(self, tenant_id: str, agent_id: UUID): _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id) payload = ComposerSavePayload.model_validate(console_ns.payload or {}) - ComposerConfigValidator.validate_save_payload(payload) + ComposerConfigValidator.validate_publish_payload(payload) AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) findings = AgentComposerService.collect_validation_findings( tenant_id=tenant_id, diff --git a/api/controllers/console/explore/message.py b/api/controllers/console/explore/message.py index 1c82f6ba9d4..2be550b2f28 100644 --- a/api/controllers/console/explore/message.py +++ b/api/controllers/console/explore/message.py @@ -27,7 +27,11 @@ from controllers.console.wraps import with_current_user from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from fields.conversation_fields import ResultResponse -from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem, SuggestedQuestionsResponse +from fields.message_fields import ( + ExploreMessageInfiniteScrollPagination, + ExploreMessageListItem, + SuggestedQuestionsResponse, +) from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from models import Account @@ -56,7 +60,7 @@ register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, Mor register_response_schema_models( console_ns, GeneratedAppResponse, - MessageInfiniteScrollPagination, + ExploreMessageInfiniteScrollPagination, ResultResponse, SuggestedQuestionsResponse, ) @@ -68,7 +72,7 @@ register_response_schema_models( ) class MessageListApi(InstalledAppResource): @console_ns.doc(params=query_params_from_model(MessageListQuery)) - @console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPagination.__name__]) + @console_ns.response(200, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__]) @with_current_user def get(self, current_user: Account, installed_app: InstalledApp): app_model = installed_app.app @@ -88,9 +92,9 @@ class MessageListApi(InstalledAppResource): str(args.first_id) if args.first_id else None, args.limit, ) - adapter = TypeAdapter(MessageListItem) + adapter = TypeAdapter(ExploreMessageListItem) items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data] - return MessageInfiniteScrollPagination( + return ExploreMessageInfiniteScrollPagination( limit=pagination.limit, has_more=pagination.has_more, data=items, diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index d8ca19b5fc3..c0d6952871e 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -41,6 +41,7 @@ from core.app.entities.queue_entities import ( QueueNodeStartedEvent, QueueNodeSucceededEvent, QueuePingEvent, + QueueReasoningChunkEvent, QueueRetrieverResourcesEvent, QueueStopEvent, QueueTextChunkEvent, @@ -62,6 +63,7 @@ from core.app.entities.task_entities import ( MessageAudioStreamResponse, MessageEndStreamResponse, PingStreamResponse, + ReasoningChunkStreamResponse, StreamResponse, WorkflowPauseStreamResponse, WorkflowTaskState, @@ -473,6 +475,17 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): self._workflow_response_converter.fetch_files_from_node_outputs(event.outputs or {}) ) + # Collect terminal reasoning (separated mode) per LLM node id for persistence. This is the + # authoritative source (outputs.reasoning_content), decoupled from the live delta stream. + # Accumulate across iteration/loop passes (same node_id) to match the live stream, which + # appends every pass under the same key — overwriting would keep only the last pass. + if event.node_type == BuiltinNodeTypes.LLM: + reasoning_content = (event.outputs or {}).get("reasoning_content") + if isinstance(reasoning_content, str) and reasoning_content: + self._task_state.metadata.reasoning[event.node_id] = ( + self._task_state.metadata.reasoning.get(event.node_id, "") + reasoning_content + ) + node_finish_resp = self._workflow_response_converter.workflow_node_finish_to_stream_response( event=event, task_id=self._application_generate_entity.task_id, @@ -535,6 +548,27 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): answer=delta_text, message_id=self._message_id, from_variable_selector=event.from_variable_selector ) + def _handle_reasoning_chunk_event( + self, event: QueueReasoningChunkEvent, **kwargs + ) -> Generator[StreamResponse, None, None]: + """Handle out-of-band reasoning chunk events. + + Pure emit: reasoning is streamed on its own channel and never written to the + answer. The terminal marker (is_final) may carry an empty reasoning string, in + which case it is still forwarded as the "thinking finished" signal. + """ + if not event.reasoning and not event.is_final: + return + yield ReasoningChunkStreamResponse( + task_id=self._application_generate_entity.task_id, + data=ReasoningChunkStreamResponse.Data( + message_id=self._message_id, + reasoning=event.reasoning, + node_id=event.from_node_id, + is_final=event.is_final, + ), + ) + def _handle_iteration_start_event( self, event: QueueIterationStartEvent, **kwargs ) -> Generator[StreamResponse, None, None]: @@ -872,6 +906,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): QueuePingEvent: self._handle_ping_event, QueueErrorEvent: self._handle_error_event, QueueTextChunkEvent: self._handle_text_chunk_event, + QueueReasoningChunkEvent: self._handle_reasoning_chunk_event, # Workflow events QueueWorkflowStartedEvent: self._handle_workflow_started_event, QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event, diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py index 69f6c5b69b7..3dae32b742b 100644 --- a/api/core/app/apps/workflow_app_runner.py +++ b/api/core/app/apps/workflow_app_runner.py @@ -24,6 +24,7 @@ from core.app.entities.queue_entities import ( QueueNodeRetryEvent, QueueNodeStartedEvent, QueueNodeSucceededEvent, + QueueReasoningChunkEvent, QueueRetrieverResourcesEvent, QueueTextChunkEvent, QueueWorkflowFailedEvent, @@ -74,6 +75,7 @@ from graphon.graph_events import ( NodeRunLoopNextEvent, NodeRunLoopStartedEvent, NodeRunLoopSucceededEvent, + NodeRunReasoningChunkEvent, NodeRunRetrieverResourceEvent, NodeRunRetryEvent, NodeRunStartedEvent, @@ -576,6 +578,16 @@ class WorkflowBasedAppRunner: in_loop_id=event.in_loop_id, ) ) + case NodeRunReasoningChunkEvent(): + self._publish_event( + QueueReasoningChunkEvent( + reasoning=event.chunk, + from_node_id=event.node_id, + is_final=event.is_final, + in_iteration_id=event.in_iteration_id, + in_loop_id=event.in_loop_id, + ) + ) case NodeRunRetrieverResourceEvent(): self._publish_event( QueueRetrieverResourcesEvent( diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py index a0e7881edeb..102d92ccf9e 100644 --- a/api/core/app/entities/queue_entities.py +++ b/api/core/app/entities/queue_entities.py @@ -40,6 +40,7 @@ class QueueEvent(StrEnum): NODE_FAILED = "node_failed" NODE_EXCEPTION = "node_exception" RETRIEVER_RESOURCES = "retriever_resources" + REASONING_CHUNK = "reasoning_chunk" ANNOTATION_REPLY = "annotation_reply" AGENT_THOUGHT = "agent_thought" MESSAGE_FILE = "message_file" @@ -197,6 +198,26 @@ class QueueTextChunkEvent(AppQueueEvent): """loop id if node is in loop""" +class QueueReasoningChunkEvent(AppQueueEvent): + """ + QueueReasoningChunkEvent entity + + Out-of-band reasoning (chain-of-thought) delta from an LLM node in "separated" + mode. It never touches the answer; it is emitted on a dedicated channel. + """ + + event: QueueEvent = QueueEvent.REASONING_CHUNK + reasoning: str + from_node_id: str | None = None + """id of the LLM node that produced this reasoning""" + is_final: bool = False + """marks the terminal reasoning chunk for the node run (may carry empty reasoning)""" + in_iteration_id: str | None = None + """iteration id if node is in iteration""" + in_loop_id: str | None = None + """loop id if node is in loop""" + + class QueueAgentMessageEvent(AppQueueEvent): """ QueueMessageEvent entity diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 3a8107e0461..f98fe6fb0be 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -27,6 +27,9 @@ class TaskStateMetadata(BaseModel): annotation_reply: AnnotationReply | None = None retriever_resources: Sequence[RetrievalSourceMetadata] = Field(default_factory=list) usage: LLMUsage | None = None + reasoning: dict[str, str] = Field(default_factory=dict) + """reasoning_content per LLM node id (separated mode), accumulated across iteration/loop + passes for that node; persisted to message_metadata""" class TaskState(BaseModel): @@ -85,6 +88,7 @@ class StreamEvent(StrEnum): LOOP_COMPLETED = "loop_completed" TEXT_CHUNK = "text_chunk" TEXT_REPLACE = "text_replace" + REASONING_CHUNK = "reasoning_chunk" AGENT_LOG = "agent_log" HUMAN_INPUT_REQUIRED = "human_input_required" HUMAN_INPUT_FORM_FILLED = "human_input_form_filled" @@ -726,6 +730,28 @@ class TextChunkStreamResponse(StreamResponse): data: Data +class ReasoningChunkStreamResponse(StreamResponse): + """ + ReasoningChunkStreamResponse entity + + Out-of-band reasoning (chain-of-thought) delta, parallel to text_chunk. Only + emitted in "separated" mode; the answer/message stream stays free of . + """ + + class Data(BaseModel): + """ + Data entity + """ + + message_id: str + reasoning: str + node_id: str | None = None + is_final: bool = False + + event: StreamEvent = StreamEvent.REASONING_CHUNK + data: Data + + class TextReplaceStreamResponse(StreamResponse): """ TextReplaceStreamResponse entity 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 366076a7c68..b815c21667e 100644 --- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py +++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py @@ -75,7 +75,7 @@ from services.agent_drive_service import AgentDriveService, decode_drive_mention from .output_failure_orchestrator import retry_idempotency_key from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError -from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids +from .runtime_feature_manifest import build_runtime_feature_manifest _DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"}) _DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation") diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh index fc118df5bc0..14778f49d5e 100755 --- a/api/docker/entrypoint.sh +++ b/api/docker/entrypoint.sh @@ -124,6 +124,7 @@ else exec python -m app else exec gunicorn \ + --no-control-socket \ --bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \ --workers ${SERVER_WORKER_AMOUNT:-1} \ --worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \ diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py index 3f9c5bf0521..ba63b8bd1f4 100644 --- a/api/fields/message_fields.py +++ b/api/fields/message_fields.py @@ -77,6 +77,13 @@ class WebMessageListItem(MessageListItem): ) +class ExploreMessageListItem(MessageListItem): + metadata: JSONValueType | None = Field( + default=None, + validation_alias="message_metadata_dict", + ) + + class MessageInfiniteScrollPagination(ResponseModel): limit: int has_more: bool @@ -89,6 +96,12 @@ class WebMessageInfiniteScrollPagination(ResponseModel): data: list[WebMessageListItem] +class ExploreMessageInfiniteScrollPagination(ResponseModel): + limit: int + has_more: bool + data: list[ExploreMessageListItem] + + class SavedMessageItem(ResponseModel): id: str inputs: dict[str, JSONValueType] diff --git a/api/libs/password.py b/api/libs/password.py index cdf55c57e5b..3313278492a 100644 --- a/api/libs/password.py +++ b/api/libs/password.py @@ -13,7 +13,7 @@ def valid_password(password): if re.match(pattern, password) is not None: return password - raise ValueError("Password must contain letters and numbers, and the length must be greater than 8.") + raise ValueError("Password must contain letters and numbers, and the length must be at least 8 characters.") def hash_password(password_str, salt_byte): diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 7b235dbd31a..f20b8b15245 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -3807,6 +3807,26 @@ Submit human input form preview for workflow | ---- | ----------- | ------ | | 200 | Workflow agent composer candidates | **application/json**: [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse)
| +### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | +| node_id | path | | Yes | string | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [WorkflowComposerCopyFromRosterPayload](#workflowcomposercopyfromrosterpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow roster agent copied to inline agent | **application/json**: [WorkflowAgentComposerResponse](#workflowagentcomposerresponse)
| + ### [POST] /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact #### Parameters @@ -6574,7 +6594,7 @@ Request body: | Code | Description | Schema | | ---- | ----------- | ------ | -| 200 | Success | **application/json**: [MessageInfiniteScrollPagination](#messageinfinitescrollpagination)
| +| 200 | Success | **application/json**: [ExploreMessageInfiniteScrollPagination](#exploremessageinfinitescrollpagination)
| ### [POST] /installed-apps/{installed_app_id}/messages/{message_id}/feedbacks #### Parameters @@ -14472,9 +14492,14 @@ Button styles for user actions. | agent_soul | [AgentSoulConfig](#agentsoulconfig) | | No | | binding | [ComposerBindingPayload](#composerbindingpayload) | | No | | client_revision_id | string | | No | +| description | string | | No | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | [AgentIconType](#agenticontype) | | No | | idempotency_key | string | | No | | new_agent_name | string | | No | | node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | No | +| role | string | | No | | save_strategy | [ComposerSaveStrategy](#composersavestrategy) | | Yes | | soul_lock | [ComposerSoulLockPayload](#composersoullockpayload) | | No | | variant | [ComposerVariant](#composervariant) | | Yes | @@ -16083,6 +16108,34 @@ Request payload for bulk downloading documents as a zip archive. | ---- | ---- | ----------- | -------- | | tool_icons | object | | No | +#### ExploreMessageInfiniteScrollPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ExploreMessageListItem](#exploremessagelistitem) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | + +#### ExploreMessageListItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | +| answer | string | | Yes | +| conversation_id | string | | Yes | +| created_at | integer | | No | +| error | string | | No | +| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes | +| feedback | [SimpleFeedback](#simplefeedback) | | No | +| id | string | | Yes | +| inputs | object | | Yes | +| message_files | [ [MessageFile](#messagefile) ] | | Yes | +| metadata | [JSONValueType](#jsonvaluetype) | | No | +| parent_message_id | string | | No | +| query | string | | Yes | +| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes | +| status | string | | Yes | + #### ExternalApiTemplateListQuery | Name | Type | Description | Required | @@ -17157,14 +17210,6 @@ Enum class for large language model mode. | upload_file_id | string | | No | | url | string | | No | -#### MessageInfiniteScrollPagination - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| data | [ [MessageListItem](#messagelistitem) ] | | Yes | -| has_more | boolean | | Yes | -| limit | integer | | Yes | - #### MessageInfiniteScrollPaginationResponse | Name | Type | Description | Required | @@ -17173,25 +17218,6 @@ Enum class for large language model mode. | has_more | boolean | | Yes | | limit | integer | | Yes | -#### MessageListItem - -| Name | Type | Description | Required | -| ---- | ---- | ----------- | -------- | -| agent_thoughts | [ [AgentThought](#agentthought) ] | | Yes | -| answer | string | | Yes | -| conversation_id | string | | Yes | -| created_at | integer | | No | -| error | string | | No | -| extra_contents | [ [HumanInputContent](#humaninputcontent) ] | | Yes | -| feedback | [SimpleFeedback](#simplefeedback) | | No | -| id | string | | Yes | -| inputs | object | | Yes | -| message_files | [ [MessageFile](#messagefile) ] | | Yes | -| parent_message_id | string | | No | -| query | string | | Yes | -| retriever_resources | [ [RetrieverResource](#retrieverresource) ] | | Yes | -| status | string | | Yes | - #### MessageListQuery | Name | Type | Description | Required | @@ -20646,6 +20672,14 @@ How a workflow node is bound to an Agent. | position_x | number | Comment X position | No | | position_y | number | Comment Y position | No | +#### WorkflowComposerCopyFromRosterPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| idempotency_key | string | | No | +| source_agent_id | string | | Yes | +| source_snapshot_id | string | | No | + #### WorkflowConversationVariableResponse | Name | Type | Description | Required | diff --git a/api/pyproject.toml b/api/pyproject.toml index 17efcef9db0..6cd1cdb484d 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "resend>=2.27.0,<3.0.0", # Emerging: newer and fast-moving, use compatible pins "fastopenapi[flask]==0.7.0", - "graphon==0.5.2", + "graphon==0.5.3", "httpx-sse==0.4.3", "json-repair==0.59.4", ] diff --git a/api/services/agent/composer_candidates.py b/api/services/agent/composer_candidates.py index b897ffc7b9a..a650b16e9bc 100644 --- a/api/services/agent/composer_candidates.py +++ b/api/services/agent/composer_candidates.py @@ -208,6 +208,7 @@ def _ref_entry( "inferred": inferred, } + def _capped(values: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]: if len(values) > MAX_CANDIDATES_PER_LIST: return values[:MAX_CANDIDATES_PER_LIST], True diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index ae47cce23c1..f0cef4d317d 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -4,6 +4,7 @@ from typing import Any from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError +from sqlalchemy.sql.elements import ColumnElement from extensions.ext_database import db from libs.helper import to_timestamp @@ -13,6 +14,7 @@ from models.agent import ( AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDriveFile, + AgentIconType, AgentKind, AgentScope, AgentSource, @@ -20,9 +22,7 @@ from models.agent import ( WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) -from models.agent_config_entities import ( - DeclaredOutputConfig, -) +from models.agent_config_entities import DeclaredOutputConfig from models.agent_config_entities import ( effective_declared_outputs as _effective_declared_outputs, ) @@ -32,6 +32,7 @@ from services.agent.composer_validator import ComposerConfigValidator from services.agent.errors import ( AgentNameConflictError, AgentNotFoundError, + AgentVersionConflictError, AgentVersionNotFoundError, InvalidComposerConfigError, ) @@ -51,6 +52,13 @@ from services.entities.agent_entities import ( # WorkflowAgentNodeBinding.workflow_version tag for the draft workflow row. # Mirrors Workflow.version when it is "draft" (see models/workflow.py). _DRAFT_WORKFLOW_VERSION = "draft" +_PUBLISH_SAVE_STRATEGIES = frozenset( + { + ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + ComposerSaveStrategy.SAVE_AS_NEW_AGENT, + ComposerSaveStrategy.SAVE_TO_ROSTER, + } +) logger = logging.getLogger(__name__) @@ -76,6 +84,13 @@ def _backfill_cli_tool_ids(agent_soul: AgentSoulConfig | None) -> None: seen_ids.add(minted) +def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> None: + if payload.save_strategy in _PUBLISH_SAVE_STRATEGIES: + ComposerConfigValidator.validate_publish_payload(payload) + return + ComposerConfigValidator.validate_draft_save_payload(payload) + + class AgentComposerService: @classmethod def load_workflow_composer(cls, *, tenant_id: str, app_id: str, node_id: str) -> dict[str, Any]: @@ -105,7 +120,7 @@ class AgentComposerService: raise ValueError("Workflow composer endpoint only accepts workflow variant") _backfill_cli_tool_ids(payload.agent_soul) - ComposerConfigValidator.validate_save_payload(payload) + _validate_composer_payload_for_strategy(payload) cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) @@ -164,6 +179,86 @@ class AgentComposerService: ) return state + @classmethod + def copy_workflow_composer_from_roster( + cls, + *, + tenant_id: str, + app_id: str, + node_id: str, + account_id: str, + source_agent_id: str, + source_snapshot_id: str | None = None, + idempotency_key: str | None = None, + ) -> dict[str, Any]: + workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id) + binding = cls._require_binding( + cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id) + ) + + if binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT and idempotency_key: + agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id) + version = cls._get_version_if_present( + tenant_id=tenant_id, + agent_id=agent.id if agent else None, + version_id=binding.current_snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=agent, version=version) + + if binding.binding_type != WorkflowAgentBindingType.ROSTER_AGENT: + raise InvalidComposerConfigError("Workflow agent node must be bound to a roster agent.") + if binding.agent_id != source_agent_id: + raise InvalidComposerConfigError("Source agent does not match the current workflow node binding.") + + source_agent = cls._require_agent(tenant_id=tenant_id, agent_id=source_agent_id) + if source_agent.scope != AgentScope.ROSTER or source_agent.status != AgentStatus.ACTIVE: + raise InvalidComposerConfigError("Source agent must be an active roster agent.") + source_version = cls._require_version( + tenant_id=tenant_id, + agent_id=source_agent.id, + version_id=source_agent.active_config_snapshot_id, + ) + if source_snapshot_id and source_snapshot_id != source_version.id: + raise AgentVersionConflictError() + + agent_soul = AgentSoulConfig.model_validate(source_version.config_snapshot_dict) + inline_agent = cls._create_workflow_only_agent( + tenant_id=tenant_id, + app_id=app_id, + workflow_id=workflow.id, + node_id=node_id, + account_id=account_id, + agent_soul=agent_soul, + name=source_agent.name, + description=source_agent.description, + role=source_agent.role, + icon_type=source_agent.icon_type, + icon=source_agent.icon, + icon_background=source_agent.icon_background, + ) + cls._copy_agent_drive_rows( + tenant_id=tenant_id, + source_agent_id=source_agent.id, + target_agent_id=inline_agent.id, + account_id=account_id, + agent_soul=agent_soul, + node_job=WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict), + ) + + binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT + binding.agent_id = inline_agent.id + binding.current_snapshot_id = inline_agent.active_config_snapshot_id + binding.updated_by = account_id + db.session.flush() + db.session.commit() + + version = cls._require_version( + tenant_id=tenant_id, + agent_id=inline_agent.id, + version_id=inline_agent.active_config_snapshot_id, + ) + return cls._serialize_workflow_state(binding=binding, agent=inline_agent, version=version) + @classmethod def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]: agent = db.session.scalar( @@ -200,7 +295,7 @@ class AgentComposerService: if payload.variant != ComposerVariant.AGENT_APP: raise ValueError("Agent App composer endpoint only accepts agent_app variant") _backfill_cli_tool_ids(payload.agent_soul) - ComposerConfigValidator.validate_save_payload(payload) + _validate_composer_payload_for_strategy(payload) cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul) if payload.agent_soul is None: raise ValueError("agent_soul is required") @@ -832,6 +927,11 @@ class AgentComposerService: tenant_id=tenant_id, account_id=account_id, name=agent_name, + description=payload.description or "", + role=payload.role or "", + icon_type=payload.icon_type, + icon=payload.icon, + icon_background=payload.icon_background, agent_soul=payload.agent_soul, operation=AgentConfigRevisionOperation.SAVE_NEW_AGENT, version_note=payload.version_note, @@ -877,6 +977,13 @@ class AgentComposerService: tenant_id=tenant_id, account_id=account_id, name=agent_name, + description=payload.description if payload.description is not None else source_agent.description, + role=payload.role if payload.role is not None else source_agent.role, + icon_type=payload.icon_type if payload.icon_type is not None else source_agent.icon_type, + icon=payload.icon if payload.icon is not None else source_agent.icon, + icon_background=payload.icon_background + if payload.icon_background is not None + else source_agent.icon_background, agent_soul=agent_soul, operation=AgentConfigRevisionOperation.SAVE_TO_ROSTER, version_note=payload.version_note, @@ -899,11 +1006,21 @@ class AgentComposerService: node_id: str, account_id: str, agent_soul: AgentSoulConfig, + name: str | None = None, + description: str = "", + role: str = "", + icon_type: Any | None = None, + icon: str | None = None, + icon_background: str | None = None, ) -> Agent: agent = Agent( tenant_id=tenant_id, - name=f"Workflow Agent {node_id}", - description="", + name=name or f"Workflow Agent {node_id}", + description=description, + role=role, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, @@ -928,6 +1045,98 @@ class AgentComposerService: agent.active_config_has_model = agent_soul_has_model(agent_soul) return agent + @classmethod + def _copy_agent_drive_rows( + cls, + *, + tenant_id: str, + source_agent_id: str, + target_agent_id: str, + account_id: str, + agent_soul: AgentSoulConfig, + node_job: WorkflowNodeJobConfig | None = None, + ) -> None: + exact_keys, prefixes = cls._drive_copy_scopes_from_agent_configs(agent_soul=agent_soul, node_job=node_job) + predicates: list[ColumnElement[bool]] = [] + if exact_keys: + predicates.append(AgentDriveFile.key.in_(sorted(exact_keys))) + predicates.extend(AgentDriveFile.key.startswith(prefix) for prefix in sorted(prefixes)) + if not predicates: + return + + source_rows = list( + db.session.scalars( + select(AgentDriveFile).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == source_agent_id, + or_(*predicates), + ) + ).all() + ) + if not source_rows: + return + + existing_target_keys = set( + db.session.scalars( + select(AgentDriveFile.key).where( + AgentDriveFile.tenant_id == tenant_id, + AgentDriveFile.agent_id == target_agent_id, + AgentDriveFile.key.in_([row.key for row in source_rows]), + ) + ).all() + ) + for row in source_rows: + if row.key in existing_target_keys: + continue + db.session.add( + AgentDriveFile( + tenant_id=tenant_id, + agent_id=target_agent_id, + key=row.key, + file_kind=row.file_kind, + file_id=row.file_id, + value_owned_by_drive=row.value_owned_by_drive, + is_skill=row.is_skill, + skill_metadata=row.skill_metadata, + size=row.size, + hash=row.hash, + mime_type=row.mime_type, + created_by=account_id, + ) + ) + + @staticmethod + def _drive_copy_scopes_from_agent_configs( + *, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None + ) -> tuple[set[str], set[str]]: + from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions + from services.agent_drive_service import decode_drive_mention_ref + + exact_keys: set[str] = set() + prefixes: set[str] = set() + + for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt): + if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: + continue + drive_key = decode_drive_mention_ref(mention.ref_id) + if not drive_key: + continue + if mention.kind == MentionKind.SKILL and "/" in drive_key: + prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/") + else: + exact_keys.add(drive_key) + + if node_job is not None: + for file_ref in node_job.metadata.file_refs or []: + if file_ref.drive_key: + exact_keys.add(file_ref.drive_key) + for output in node_job.declared_outputs: + benchmark_ref = output.check.benchmark_file_ref if output.check and output.check.enabled else None + if benchmark_ref and benchmark_ref.drive_key: + exact_keys.add(benchmark_ref.drive_key) + + return exact_keys, prefixes + @classmethod def _create_roster_agent_for_composer( cls, @@ -938,11 +1147,20 @@ class AgentComposerService: agent_soul: AgentSoulConfig, operation: AgentConfigRevisionOperation, version_note: str | None, + description: str = "", + role: str = "", + icon_type: AgentIconType | None = None, + icon: str | None = None, + icon_background: str | None = None, ) -> Agent: agent = Agent( tenant_id=tenant_id, name=name, - description="", + description=description, + role=role, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.WORKFLOW, diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py index 4a9d0a5a9a9..4e7d4ff3c63 100644 --- a/api/services/agent/composer_validator.py +++ b/api/services/agent/composer_validator.py @@ -50,7 +50,7 @@ _DANGEROUS_ACK_KEYS = ( class ComposerConfigValidator: @classmethod - def validate_save_payload(cls, payload: ComposerSavePayload) -> None: + def validate_draft_save_payload(cls, payload: ComposerSavePayload) -> None: if ( payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked @@ -59,6 +59,13 @@ class ComposerConfigValidator: ): raise AgentSoulLockedError() + @classmethod + def validate_save_payload(cls, payload: ComposerSavePayload) -> None: + cls.validate_publish_payload(payload) + + @classmethod + def validate_publish_payload(cls, payload: ComposerSavePayload) -> None: + cls.validate_draft_save_payload(payload) if payload.agent_soul is not None: cls.validate_agent_soul(payload.agent_soul) if payload.node_job is not None: diff --git a/api/services/agent/errors.py b/api/services/agent/errors.py index dcc8f69961f..6a1dc6fb628 100644 --- a/api/services/agent/errors.py +++ b/api/services/agent/errors.py @@ -17,6 +17,10 @@ class AgentArchivedError(Conflict): description = "Archived agent cannot be modified." +class AgentVersionConflictError(Conflict): + description = "Agent config version changed. Please reload and try again." + + class AgentSoulLockedError(BadRequest): description = "Agent Soul is locked for this workflow node." diff --git a/api/services/agent/workflow_publish_service.py b/api/services/agent/workflow_publish_service.py index 3d39419d794..eb3766996bf 100644 --- a/api/services/agent/workflow_publish_service.py +++ b/api/services/agent/workflow_publish_service.py @@ -8,17 +8,25 @@ from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.orm import Session -from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator +from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator from models.agent import ( Agent, AgentConfigSnapshot, + AgentDriveFile, AgentScope, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) -from models.agent_config_entities import DeclaredOutputConfig, WorkflowNodeJobConfig +from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig from models.workflow import Workflow +from services.agent.composer_validator import ComposerConfigValidator +from services.entities.agent_entities import ( + ComposerSavePayload, + ComposerSaveStrategy, + ComposerSoulLockPayload, + ComposerVariant, +) class WorkflowAgentPublishService: @@ -67,11 +75,131 @@ class WorkflowAgentPublishService: @classmethod def validate_agent_nodes_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None: WorkflowAgentNodeValidator.validate_published_workflow(session=session, workflow=draft_workflow) + cls._validate_composer_configs_for_publish(session=session, draft_workflow=draft_workflow) @classmethod def validate_agent_nodes_for_draft_sync(cls, *, session: Session, draft_workflow: Workflow) -> None: WorkflowAgentNodeValidator.validate_draft_workflow(session=session, workflow=draft_workflow) + @classmethod + def _validate_composer_configs_for_publish(cls, *, session: Session, draft_workflow: Workflow) -> None: + node_ids = { + node_id for node_id, _node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(draft_workflow.graph_dict) + } + if not node_ids: + return + + bindings = session.scalars( + select(WorkflowAgentNodeBinding).where( + WorkflowAgentNodeBinding.tenant_id == draft_workflow.tenant_id, + WorkflowAgentNodeBinding.app_id == draft_workflow.app_id, + WorkflowAgentNodeBinding.workflow_id == draft_workflow.id, + WorkflowAgentNodeBinding.workflow_version == draft_workflow.version, + WorkflowAgentNodeBinding.node_id.in_(node_ids), + ) + ).all() + for binding in bindings: + cls._validate_binding_composer_config_for_publish(session=session, binding=binding) + + @classmethod + def _validate_binding_composer_config_for_publish( + cls, + *, + session: Session, + binding: WorkflowAgentNodeBinding, + ) -> None: + if not binding.agent_id: + return + + agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == binding.tenant_id, + Agent.id == binding.agent_id, + ) + .limit(1) + ) + if agent is None: + return + + snapshot_id = ( + agent.active_config_snapshot_id + if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT + else binding.current_snapshot_id + ) + if snapshot_id is None: + return + + snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == binding.tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == snapshot_id, + ) + .limit(1) + ) + if snapshot is None: + return + + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) + payload = ComposerSavePayload.model_construct( + variant=ComposerVariant.WORKFLOW, + save_strategy=ComposerSaveStrategy.NODE_JOB_ONLY, + soul_lock=ComposerSoulLockPayload(locked=False), + agent_soul=agent_soul, + node_job=node_job, + ) + ComposerConfigValidator.validate_publish_payload(payload) + # ENG-623 §4.4: drive-backed refs must point at real drive rows before + # publishing. This stays out of composer save so autosave/save-draft can + # persist incomplete refs and surface them as non-blocking findings. + cls._require_drive_refs_resolved_for_publish(session=session, binding=binding, agent_soul=agent_soul) + + @classmethod + def _require_drive_refs_resolved_for_publish( + cls, + *, + session: Session, + binding: WorkflowAgentNodeBinding, + agent_soul: AgentSoulConfig, + ) -> None: + from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions + from services.agent_drive_service import decode_drive_mention_ref + + wanted_keys: dict[str, tuple[str, str]] = {} + for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt): + if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}: + continue + drive_key = decode_drive_mention_ref(mention.ref_id) + if not drive_key: + continue + code = "skill_ref_dangling" if mention.kind == MentionKind.SKILL else "file_ref_dangling" + wanted_keys[drive_key] = (code, mention.label or drive_key) + if not wanted_keys or not binding.agent_id: + return + + existing_keys = set( + session.scalars( + select(AgentDriveFile.key).where( + AgentDriveFile.tenant_id == binding.tenant_id, + AgentDriveFile.agent_id == binding.agent_id, + AgentDriveFile.key.in_(sorted(wanted_keys)), + ) + ).all() + ) + messages: list[str] = [] + for key, (code, display) in wanted_keys.items(): + if key in existing_keys: + continue + kind = "skill" if code == "skill_ref_dangling" else "file" + messages.append(f"{code}: {kind} '{display}' has no drive entry for key '{key}'.") + if messages: + raise WorkflowAgentNodeValidationError( + f"Workflow Agent node {binding.node_id} has invalid Agent Soul drive refs: {'; '.join(messages)}" + ) + @classmethod def sync_agent_bindings_for_draft( cls, diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index 7ded11a1658..96c362b3dfc 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -183,7 +183,11 @@ class EnterpriseRequest(BaseRequest): if account_id: inner_headers[INNER_ACCOUNT_ID_HEADER] = account_id - if not cls.base_url.startswith("http") or not cls.base_url.startswith("https") or not cls.base_url: + if ( + not cls.rbac_base_url.startswith("http") + and not cls.rbac_base_url.startswith("https") + and not cls.rbac_base_url + ): raise ValueError("ENTERPRISE_RBAC_API_URL is required when RBAC_ENABLED=true") url = f"{cls.rbac_base_url}{endpoint}" diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index be94925b94a..b5585932b29 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -534,6 +534,31 @@ def _legacy_role_permission_keys(role: TenantAccountRole) -> list[str]: ) +def _legacy_member_roles_response( + tenant_id: str, member_account_id: str, role: TenantAccountRole | str | None +) -> MemberRolesResponse: + if not role: + return MemberRolesResponse(account_id=member_account_id, roles=[]) + + tenant_role = TenantAccountRole(role) + role_value = tenant_role.value + return MemberRolesResponse( + account_id=member_account_id, + roles=[ + RBACRole( + id=role_value, + name=role_value, + description="", + is_builtin=True, + type="", + permission_keys=_legacy_role_permission_keys(tenant_role), + role_tag="owner" if tenant_role == TenantAccountRole.OWNER else role_value, + tenant_id=tenant_id, + ) + ], + ) + + def _legacy_my_permissions(tenant_id: str, account_id: str | None) -> MyPermissionsResponse: if not account_id: return MyPermissionsResponse() @@ -1582,23 +1607,7 @@ class RBACService: TenantAccountJoin.account_id == member_account_id, ) ) - return MemberRolesResponse( - account_id=member_account_id, - roles=[ - RBACRole( - id=role, - name=role, - description="", - is_builtin=True, - type="", - permission_keys=_legacy_role_permission_keys(role), - role_tag="owner" if role == "owner" else role, - tenant_id=tenant_id, - ) - ] - if role - else [], - ) + return _legacy_member_roles_response(tenant_id, member_account_id, role) @staticmethod def batch_get( @@ -1629,6 +1638,36 @@ class RBACService: member_account_id: str, role_ids: list[str], ) -> MemberRolesResponse: + if not dify_config.RBAC_ENABLED: + if len(role_ids) != 1: + raise ValueError("Legacy workspace member role update requires exactly one role.") + + tenant_role = TenantAccountRole(role_ids[0]) + with session_factory.create_session() as session: + target_member_join = session.scalar( + select(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == tenant_id, + TenantAccountJoin.account_id == member_account_id, + ) + ) + if not target_member_join: + raise ValueError("Member not in tenant.") + + if tenant_role == TenantAccountRole.OWNER: + current_owner_join = session.scalar( + select(TenantAccountJoin).where( + TenantAccountJoin.tenant_id == tenant_id, + TenantAccountJoin.role == TenantAccountRole.OWNER, + ) + ) + if current_owner_join and current_owner_join.account_id != member_account_id: + current_owner_join.role = TenantAccountRole.ADMIN + + target_member_join.role = tenant_role + session.commit() + + return _legacy_member_roles_response(tenant_id, member_account_id, tenant_role) + data = _inner_call( "PUT", f"{_INNER_PREFIX}/members/rbac-roles", diff --git a/api/services/entities/agent_entities.py b/api/services/entities/agent_entities.py index e7b5cbd7c6d..a8634bceb09 100644 --- a/api/services/entities/agent_entities.py +++ b/api/services/entities/agent_entities.py @@ -42,6 +42,11 @@ class ComposerSavePayload(BaseModel): idempotency_key: str | None = None client_revision_id: str | None = None new_agent_name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + role: str | None = Field(default=None, max_length=255) + icon_type: AgentIconType | None = None + icon: str | None = Field(default=None, max_length=255) + icon_background: str | None = Field(default=None, max_length=255) @model_validator(mode="after") def validate_variant_sections(self) -> "ComposerSavePayload": @@ -58,6 +63,12 @@ class ComposerSavePayload(BaseModel): return self +class WorkflowComposerCopyFromRosterPayload(BaseModel): + source_agent_id: str = Field(min_length=1, max_length=255) + source_snapshot_id: str | None = Field(default=None, max_length=255) + idempotency_key: str | None = Field(default=None, max_length=255) + + class RosterAgentCreatePayload(BaseModel): name: str = Field(min_length=1, max_length=255) mode: Literal["agent"] = "agent" 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 ec3de9928a5..3d84f899379 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 @@ -15,6 +15,7 @@ from controllers.console.agent.composer import ( AgentComposerValidateApi, WorkflowAgentComposerApi, WorkflowAgentComposerCandidatesApi, + WorkflowAgentComposerCopyFromRosterApi, WorkflowAgentComposerImpactApi, WorkflowAgentComposerSaveToRosterApi, WorkflowAgentComposerValidateApi, @@ -972,7 +973,7 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( "save_workflow_composer", lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]), ) - monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) monkeypatch.setattr( composer_controller.AgentComposerService, "resolve_workflow_node_agent_id", lambda **kwargs: None ) @@ -1017,6 +1018,58 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save( )["save_options"] == ["node_job_only"] +def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str) -> None: + app_model = SimpleNamespace(id="app-1") + captured: dict[str, object] = {} + + def fake_copy_from_roster(**kwargs): + captured.update(kwargs) + return _workflow_composer_response( + binding={ + "id": "binding-1", + "binding_type": "inline_agent", + "agent_id": "inline-agent-1", + "current_snapshot_id": "inline-version-1", + "workflow_id": "workflow-1", + "node_id": kwargs["node_id"], + }, + agent={ + "id": "inline-agent-1", + "name": "Nadia", + "description": "", + "scope": "workflow_only", + "status": "active", + }, + active_config_snapshot={"id": "inline-version-1", "version": 1}, + ) + + monkeypatch.setattr( + composer_controller.AgentComposerService, "copy_workflow_composer_from_roster", fake_copy_from_roster + ) + + with app.test_request_context( + json={ + "source_agent_id": "roster-agent-1", + "source_snapshot_id": "roster-version-1", + "idempotency_key": "copy-1", + } + ): + result = unwrap(WorkflowAgentComposerCopyFromRosterApi.post)( + WorkflowAgentComposerCopyFromRosterApi(), "tenant-1", account_id, app_model, "node-1" + ) + + assert result["binding"]["binding_type"] == "inline_agent" + assert captured == { + "tenant_id": "tenant-1", + "app_id": "app-1", + "node_id": "node-1", + "account_id": account_id, + "source_agent_id": "roster-agent-1", + "source_snapshot_id": "roster-version-1", + "idempotency_key": "copy-1", + } + + def test_workflow_impact_returns_empty_without_version(app: Flask) -> None: payload = {"variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value} @@ -1067,7 +1120,7 @@ def test_agent_composer_routes_resolve_app_from_agent_id( "save_agent_app_composer", save_agent_app_composer, ) - monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) monkeypatch.setattr( composer_controller.AgentComposerService, "collect_validation_findings", diff --git a/api/tests/unit_tests/core/app/entities/test_queue_entities.py b/api/tests/unit_tests/core/app/entities/test_queue_entities.py index 7c21b00966e..a930d7325d4 100644 --- a/api/tests/unit_tests/core/app/entities/test_queue_entities.py +++ b/api/tests/unit_tests/core/app/entities/test_queue_entities.py @@ -1,4 +1,4 @@ -from core.app.entities.queue_entities import QueueStopEvent +from core.app.entities.queue_entities import QueueEvent, QueueReasoningChunkEvent, QueueStopEvent class TestQueueEntities: @@ -10,3 +10,17 @@ class TestQueueEntities: event = QueueStopEvent(stopped_by=QueueStopEvent.StopBy.USER_MANUAL) event.stopped_by = "unknown" assert event.get_stop_reason() == "Stopped by unknown reason." + + def test_reasoning_chunk_event_defaults(self): + event = QueueReasoningChunkEvent(reasoning="thinking", from_node_id="llm") + assert event.event == QueueEvent.REASONING_CHUNK + assert event.reasoning == "thinking" + assert event.from_node_id == "llm" + assert event.is_final is False + assert event.in_iteration_id is None + assert event.in_loop_id is None + + def test_reasoning_chunk_event_terminal_marker_allows_empty_reasoning(self): + event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm", is_final=True) + assert event.reasoning == "" + assert event.is_final is True diff --git a/api/tests/unit_tests/core/app/entities/test_task_entities.py b/api/tests/unit_tests/core/app/entities/test_task_entities.py index 7c797806411..19d49a28877 100644 --- a/api/tests/unit_tests/core/app/entities/test_task_entities.py +++ b/api/tests/unit_tests/core/app/entities/test_task_entities.py @@ -1,10 +1,15 @@ +import json + from core.app.entities.task_entities import ( NodeFinishStreamResponse, NodeRetryStreamResponse, NodeStartStreamResponse, + ReasoningChunkStreamResponse, StreamEvent, + TaskStateMetadata, ) from graphon.enums import WorkflowNodeExecutionStatus +from graphon.model_runtime.utils.encoders import jsonable_encoder class TestTaskEntities: @@ -76,3 +81,41 @@ class TestTaskEntities: assert payload["event"] == StreamEvent.NODE_RETRY.value assert payload["data"]["retry_index"] == 2 assert payload["data"]["outputs"] is None + + def test_reasoning_chunk_stream_response_shape(self): + response = ReasoningChunkStreamResponse( + task_id="task-1", + data=ReasoningChunkStreamResponse.Data( + message_id="msg-1", + reasoning="let me think", + node_id="llm", + is_final=False, + ), + ) + + payload = response.model_dump() + + assert payload["event"] == StreamEvent.REASONING_CHUNK + assert payload["task_id"] == "task-1" + assert payload["data"]["message_id"] == "msg-1" + assert payload["data"]["reasoning"] == "let me think" + assert payload["data"]["node_id"] == "llm" + assert payload["data"]["is_final"] is False + + def test_task_state_metadata_reasoning_round_trips(self): + # The persistence path serializes the whole metadata to message_metadata via + # model_dump -> jsonable_encoder -> json.dumps, then reads back with json.loads. + metadata = TaskStateMetadata() + metadata.reasoning["llm"] = "first" + metadata.reasoning["llm2"] = "second" + + serialized = json.dumps(jsonable_encoder(metadata.model_dump())) + restored = json.loads(serialized) + + assert restored["reasoning"] == {"llm": "first", "llm2": "second"} + + def test_task_state_metadata_reasoning_defaults_empty(self): + # Old rows / runs without reasoning serialize to an empty dict, never null. + metadata = TaskStateMetadata() + restored = json.loads(json.dumps(jsonable_encoder(metadata.model_dump()))) + assert restored["reasoning"] == {} diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py index 79581cc8811..d437c565949 100644 --- a/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_node.py @@ -47,7 +47,12 @@ from graphon.model_runtime.entities.model_entities import ( ParameterType, ) from graphon.model_runtime.model_providers.model_provider_factory import ModelProviderFactory -from graphon.node_events import ModelInvokeCompletedEvent, RunRetrieverResourceEvent, StreamChunkEvent +from graphon.node_events import ( + ModelInvokeCompletedEvent, + RunRetrieverResourceEvent, + StreamChunkEvent, + StreamReasoningEvent, +) from graphon.nodes.base.entities import VariableSelector from graphon.nodes.llm import llm_utils from graphon.nodes.llm.entities import ( @@ -1576,9 +1581,13 @@ def test_handle_invoke_result_streaming_collects_text_metrics_and_structured_out assert events[0] == first_chunk - assert events[1] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False) + assert events[1] == StreamReasoningEvent(selector=["node-1", "reasoning_content"], chunk="plan", is_final=False) - completed = events[2] + assert events[2] == StreamChunkEvent(selector=["node-1", "text"], chunk="answer", is_final=False) + + assert events[3] == StreamReasoningEvent(selector=["node-1", "reasoning_content"], chunk="", is_final=True) + + completed = events[4] assert isinstance(completed, ModelInvokeCompletedEvent) assert completed.text == "answer" assert completed.reasoning_content == "plan" diff --git a/api/tests/unit_tests/fields/test_message_fields.py b/api/tests/unit_tests/fields/test_message_fields.py new file mode 100644 index 00000000000..8a4eadaf744 --- /dev/null +++ b/api/tests/unit_tests/fields/test_message_fields.py @@ -0,0 +1,36 @@ +from fields.message_fields import ExploreMessageListItem, MessageListItem + + +def _base_kwargs(): + return { + "id": "m1", + "conversation_id": "c1", + "inputs": {}, + "query": "hi", + "answer": "answer", + "retriever_resources": [], + "agent_thoughts": [], + "message_files": [], + "status": "normal", + "extra_contents": [], + } + + +class TestExploreMessageListItem: + def test_exposes_metadata_for_history_rehydration(self): + # The Explore/installed-app surface must surface message_metadata (incl. reasoning) + # so the chat-with-history client can rehydrate the thinking panel on reload. + item = ExploreMessageListItem(**_base_kwargs(), metadata={"reasoning": {"llm": "thinking..."}}) + + payload = item.model_dump(mode="json") + + assert payload["metadata"] == {"reasoning": {"llm": "thinking..."}} + + def test_metadata_defaults_to_none(self): + item = ExploreMessageListItem(**_base_kwargs()) + assert item.model_dump(mode="json")["metadata"] is None + + def test_base_message_list_item_has_no_metadata(self): + # Guard the public service-API contract: the base item must not leak metadata. + payload = MessageListItem(**_base_kwargs()).model_dump(mode="json") + assert "metadata" not in payload diff --git a/api/tests/unit_tests/libs/test_password.py b/api/tests/unit_tests/libs/test_password.py index 79fc792cc5f..3cdf22e8051 100644 --- a/api/tests/unit_tests/libs/test_password.py +++ b/api/tests/unit_tests/libs/test_password.py @@ -35,6 +35,13 @@ class TestValidPassword: with pytest.raises(ValueError): valid_password("") + def test_should_reject_password_shorter_than_minimum_length(self): + """A 7-character password with letters and numbers is rejected for length.""" + with pytest.raises(ValueError) as exc_info: + valid_password("abc1234") + + assert "at least 8" in str(exc_info.value) + class TestPasswordHashing: """Test password hashing and comparison""" diff --git a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py index ba32222b70a..de77da5ef99 100644 --- a/api/tests/unit_tests/services/agent/test_agent_composer_entities.py +++ b/api/tests/unit_tests/services/agent/test_agent_composer_entities.py @@ -4,7 +4,7 @@ from pydantic import ValidationError from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import AgentSoulLockedError, PlaintextSecretNotAllowedError +from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError from services.entities.agent_entities import ( AgentSoulConfig, ComposerSavePayload, @@ -65,6 +65,24 @@ def test_locked_workflow_node_job_only_allows_inline_soul_payload(): ComposerConfigValidator.validate_save_payload(payload) +def test_draft_save_payload_skips_publish_only_agent_soul_validation(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP, + "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + "agent_soul": { + "prompt": {"system_prompt": "no human reference yet"}, + "human": {"contacts": [{"id": "human-1", "name": "Reviewer"}]}, + "env": {"variables": [{"name": "bad-name"}]}, + }, + } + ) + + ComposerConfigValidator.validate_draft_save_payload(payload) + with pytest.raises(InvalidComposerConfigError): + ComposerConfigValidator.validate_publish_payload(payload) + + def test_agent_app_soul_allows_app_features_and_variables(): payload = ComposerSavePayload.model_validate( { @@ -88,6 +106,28 @@ def test_agent_app_soul_allows_app_features_and_variables(): assert payload.agent_soul.app_variables[0].name == "company_name" +def test_composer_save_payload_accepts_new_roster_metadata(): + payload = ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.WORKFLOW, + "save_strategy": ComposerSaveStrategy.SAVE_TO_ROSTER, + "new_agent_name": "Research Agent", + "description": "Finds relevant sources.", + "role": "Research Assistant", + "icon_type": "emoji", + "icon": "search", + "icon_background": "#E0F2FE", + } + ) + + assert payload.new_agent_name == "Research Agent" + assert payload.description == "Finds relevant sources." + assert payload.role == "Research Assistant" + assert payload.icon_type == "emoji" + assert payload.icon == "search" + assert payload.icon_background == "#E0F2FE" + + def test_knowledge_query_mode_uses_stable_backend_enums(): config = AgentSoulConfig.model_validate( { 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 36e7c2736bc..0465f66c4fb 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -4,11 +4,13 @@ from types import SimpleNamespace import pytest +from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError from models.agent import ( Agent, AgentConfigRevisionOperation, AgentConfigSnapshot, AgentDebugConversation, + AgentDriveFile, AgentKind, AgentScope, AgentSource, @@ -30,7 +32,7 @@ from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_service import AgentComposerService from services.agent.composer_validator import ComposerConfigValidator -from services.agent.errors import InvalidComposerConfigError +from services.agent.errors import AgentVersionConflictError, InvalidComposerConfigError from services.agent.roster_service import AgentRosterService from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.app_service import AppListParams, AppService @@ -168,7 +170,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, calls = [] monkeypatch.setattr(composer_service.db, "session", fake_session) - monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) monkeypatch.setattr( @@ -221,12 +223,52 @@ def test_save_workflow_composer_rejects_agent_app_variant(): ) +def _duplicate_env_secret_payload(strategy: ComposerSaveStrategy) -> ComposerSavePayload: + return ComposerSavePayload.model_validate( + { + "variant": ComposerVariant.AGENT_APP.value, + "save_strategy": strategy.value, + "agent_soul": { + "prompt": {"system_prompt": "x"}, + "env": { + "variables": [{"name": "TOKEN", "value": "plain"}], + "secret_refs": [{"name": "TOKEN", "value": "credential-1"}], + }, + }, + } + ) + + +@pytest.mark.parametrize( + "strategy", + [ + ComposerSaveStrategy.NODE_JOB_ONLY, + ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, + ], +) +def test_draft_save_strategies_skip_publish_validation(strategy: ComposerSaveStrategy): + composer_service._validate_composer_payload_for_strategy(_duplicate_env_secret_payload(strategy)) + + +@pytest.mark.parametrize( + "strategy", + [ + ComposerSaveStrategy.SAVE_AS_NEW_VERSION, + ComposerSaveStrategy.SAVE_AS_NEW_AGENT, + ComposerSaveStrategy.SAVE_TO_ROSTER, + ], +) +def test_publish_save_strategies_run_publish_validation(strategy: ComposerSaveStrategy): + with pytest.raises(InvalidComposerConfigError, match="duplicate env/secret name 'TOKEN'"): + composer_service._validate_composer_payload_for_strategy(_duplicate_env_secret_payload(strategy)) + + def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession(scalar=[None]) created_version = SimpleNamespace(id="version-1") monkeypatch.setattr(composer_service.db, "session", fake_session) - monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) monkeypatch.setattr(AgentComposerService, "_create_config_version", lambda **kwargs: created_version) monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True}) payload = ComposerSavePayload.model_validate( @@ -256,7 +298,7 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.Mon updated = {} monkeypatch.setattr(composer_service.db, "session", fake_session) - monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_save_payload", lambda payload: None) + monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id="version-1")) monkeypatch.setattr( AgentComposerService, @@ -374,9 +416,28 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) workflow_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") - roster_agent = SimpleNamespace(id="roster-agent-1", active_config_snapshot_id="roster-version-1", name="Roster") + roster_agent = SimpleNamespace( + id="roster-agent-1", + active_config_snapshot_id="roster-version-1", + name="Roster", + description="Source description", + role="Source role", + icon_type="emoji", + icon="source", + icon_background="#FFFFFF", + ) + create_roster_calls = [] monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) - monkeypatch.setattr(AgentComposerService, "_create_roster_agent_for_composer", lambda **kwargs: roster_agent) + + def fake_create_roster_agent_for_composer(**kwargs): + create_roster_calls.append(kwargs) + return roster_agent + + monkeypatch.setattr( + AgentComposerService, + "_create_roster_agent_for_composer", + fake_create_roster_agent_for_composer, + ) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr( AgentComposerService, @@ -402,6 +463,11 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk "agent_soul": {"prompt": {"system_prompt": "new"}}, "node_job": {"workflow_prompt": "use prior output"}, "new_agent_name": "Copied Agent", + "description": "Copied description", + "role": "Copied role", + "icon_type": "emoji", + "icon": "copied", + "icon_background": "#E0F2FE", } ) existing_binding = WorkflowAgentNodeBinding(agent_id="inline-agent-1", current_snapshot_id="inline-version-1") @@ -459,6 +525,14 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.Monk assert new_agent_binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT assert save_to_roster_binding.agent_id == "roster-agent-1" assert new_version_binding.current_snapshot_id == "new-version-1" + assert create_roster_calls[0]["description"] == "Copied description" + assert create_roster_calls[0]["role"] == "Copied role" + assert create_roster_calls[0]["icon"] == "copied" + assert create_roster_calls[0]["icon_background"] == "#E0F2FE" + assert create_roster_calls[1]["description"] == "Copied description" + assert create_roster_calls[1]["role"] == "Copied role" + assert create_roster_calls[1]["icon"] == "copied" + assert create_roster_calls[1]["icon_background"] == "#E0F2FE" def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch): @@ -674,6 +748,428 @@ def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypat ) +def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_node_job( + monkeypatch: pytest.MonkeyPatch, +): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + workflow = SimpleNamespace(id="workflow-1") + node_job = WorkflowNodeJobConfig(workflow_prompt="keep this node task") + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="old-roster-version", + node_job_config=node_job, + ) + roster_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Nadia", + description="Clarification Drafter", + role="Clarifies tenders", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="roster-version-2", + ) + source_version = AgentConfigSnapshot( + id="roster-version-2", + tenant_id="tenant-1", + agent_id="roster-agent-1", + version=2, + config_snapshot='{"prompt":{"system_prompt":"copy me"}}', + ) + inline_agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Nadia", + description="Clarification Drafter", + role="Clarifies tenders", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-version-1", + ) + captured: dict[str, object] = {} + + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: workflow) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) + + def fake_create_workflow_only_agent(**kwargs): + captured["create"] = kwargs + return inline_agent + + def fake_copy_drive_rows(**kwargs): + captured["drive"] = kwargs + + monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) + monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: { + "binding": { + "binding_type": kwargs["binding"].binding_type.value, + "agent_id": kwargs["binding"].agent_id, + "current_snapshot_id": kwargs["binding"].current_snapshot_id, + }, + "node_job": kwargs["binding"].node_job_config_dict, + }, + ) + + state = AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + source_snapshot_id="roster-version-2", + ) + + assert state["binding"]["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value + assert state["binding"]["agent_id"] == "inline-agent-1" + assert state["node_job"]["workflow_prompt"] == "keep this node task" + assert binding.node_job_config is node_job + create_kwargs = captured["create"] + assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me" + assert create_kwargs["name"] == "Nadia" + assert create_kwargs["role"] == "Clarifies tenders" + drive_kwargs = captured["drive"] + assert drive_kwargs["source_agent_id"] == "roster-agent-1" + assert drive_kwargs["target_agent_id"] == "inline-agent-1" + assert fake_session.commits == 1 + + +def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr( + AgentComposerService, + "_get_workflow_binding", + lambda **kwargs: WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="roster-agent-1", + current_snapshot_id="roster-version-1", + node_job_config=WorkflowNodeJobConfig(), + ), + ) + roster_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Nadia", + scope=AgentScope.ROSTER, + source=AgentSource.AGENT_APP, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="roster-version-2", + ) + source_version = AgentConfigSnapshot( + id="roster-version-2", + tenant_id="tenant-1", + agent_id="roster-agent-1", + version=2, + config_snapshot='{"prompt":{"system_prompt":"copy me"}}', + ) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) + monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) + + with pytest.raises(AgentVersionConflictError): + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + source_snapshot_id="roster-version-1", + ) + + +def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline(monkeypatch: pytest.MonkeyPatch): + inline_binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=WorkflowAgentBindingType.INLINE_AGENT, + agent_id="inline-agent-1", + current_snapshot_id="inline-version-1", + ) + inline_agent = Agent( + id="inline-agent-1", + tenant_id="tenant-1", + name="Inline", + scope=AgentScope.WORKFLOW_ONLY, + source=AgentSource.WORKFLOW, + status=AgentStatus.ACTIVE, + active_config_snapshot_id="inline-version-1", + ) + inline_version = AgentConfigSnapshot( + id="inline-version-1", + tenant_id="tenant-1", + agent_id="inline-agent-1", + version=1, + config_snapshot='{"prompt":{"system_prompt":"inline"}}', + ) + monkeypatch.setattr(composer_service.db, "session", FakeSession()) + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: inline_binding) + monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: inline_agent) + monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: inline_version) + monkeypatch.setattr( + AgentComposerService, + "_serialize_workflow_state", + lambda **kwargs: {"binding_type": kwargs["binding"].binding_type.value}, + ) + + state = AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + idempotency_key="same-click", + ) + + assert state == {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value} + + +@pytest.mark.parametrize( + ("binding_agent_id", "binding_type", "source_scope", "source_status", "expected_message"), + [ + ( + "roster-agent-1", + WorkflowAgentBindingType.INLINE_AGENT, + AgentScope.ROSTER, + AgentStatus.ACTIVE, + "must be bound to a roster agent", + ), + ( + "other-agent", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.ROSTER, + AgentStatus.ACTIVE, + "does not match", + ), + ( + "roster-agent-1", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.WORKFLOW_ONLY, + AgentStatus.ACTIVE, + "must be an active roster agent", + ), + ( + "roster-agent-1", + WorkflowAgentBindingType.ROSTER_AGENT, + AgentScope.ROSTER, + AgentStatus.ARCHIVED, + "must be an active roster agent", + ), + ], +) +def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding( + monkeypatch: pytest.MonkeyPatch, + binding_agent_id: str, + binding_type: WorkflowAgentBindingType, + source_scope: AgentScope, + source_status: AgentStatus, + expected_message: str, +): + binding = WorkflowAgentNodeBinding( + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version="draft", + node_id="node-1", + binding_type=binding_type, + agent_id=binding_agent_id, + current_snapshot_id="version-1", + node_job_config=WorkflowNodeJobConfig(), + ) + source_agent = Agent( + id="roster-agent-1", + tenant_id="tenant-1", + name="Source", + scope=source_scope, + source=AgentSource.AGENT_APP, + status=source_status, + active_config_snapshot_id="version-1", + ) + monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) + monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) + monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: source_agent) + + with pytest.raises(InvalidComposerConfigError, match=expected_message): + AgentComposerService.copy_workflow_composer_from_roster( + tenant_id="tenant-1", + app_id="app-1", + node_id="node-1", + account_id="account-1", + source_agent_id="roster-agent-1", + ) + + +def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch): + skill_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="tender-analyzer/SKILL.md", + file_kind="tool_file", + file_id="tool-file-1", + value_owned_by_drive=True, + is_skill=True, + skill_metadata='{"name":"Tender Analyzer"}', + size=10, + mime_type="text/markdown", + ) + script_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="tender-analyzer/scripts/run.sh", + file_kind="tool_file", + file_id="tool-file-2", + value_owned_by_drive=True, + size=20, + mime_type="text/x-shellscript", + ) + file_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="files/qna.pdf", + file_kind="upload_file", + file_id="upload-file-1", + value_owned_by_drive=False, + size=30, + mime_type="application/pdf", + ) + fake_session = FakeSession(scalars=[[skill_row, script_row, file_row], []]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate( + { + "prompt": { + "system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]", + }, + } + ) + node_job = WorkflowNodeJobConfig.model_validate( + {"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}} + ) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + node_job=node_job, + ) + + copied = [row for row in fake_session.added if isinstance(row, AgentDriveFile)] + assert [row.key for row in copied] == [ + "tender-analyzer/SKILL.md", + "tender-analyzer/scripts/run.sh", + "files/qna.pdf", + ] + assert {row.agent_id for row in copied} == {"inline-agent-1"} + assert copied[0].file_id == "tool-file-1" + assert copied[0].is_skill is True + assert copied[2].value_owned_by_drive is False + + +def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys(monkeypatch: pytest.MonkeyPatch): + fake_session = FakeSession() + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}}) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + ) + + assert fake_session.added == [] + + +def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch): + source_row = AgentDriveFile( + tenant_id="tenant-1", + agent_id="roster-agent-1", + key="files/qna.pdf", + file_kind="upload_file", + file_id="upload-file-1", + value_owned_by_drive=False, + size=30, + mime_type="application/pdf", + ) + fake_session = FakeSession(scalars=[[source_row], ["files/qna.pdf"]]) + monkeypatch.setattr(composer_service.db, "session", fake_session) + agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}}) + + AgentComposerService._copy_agent_drive_rows( + tenant_id="tenant-1", + source_agent_id="roster-agent-1", + target_agent_id="inline-agent-1", + account_id="account-1", + agent_soul=agent_soul, + ) + + assert [row for row in fake_session.added if isinstance(row, AgentDriveFile)] == [] + + +def test_drive_copy_scopes_include_declared_output_benchmark_files(): + agent_soul = AgentSoulConfig.model_validate( + { + "prompt": { + "system_prompt": ( + "[§file:files/source.pdf:source.pdf§] " + "[§knowledge:dataset-1:Docs§] " + "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]" + ) + }, + } + ) + node_job = WorkflowNodeJobConfig.model_validate( + { + "declared_outputs": [ + { + "name": "qna_report", + "type": "file", + "check": { + "enabled": True, + "prompt": "Compare the generated file with the benchmark.", + "benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"}, + }, + }, + { + "name": "summary", + "type": "string", + "check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}}, + }, + ], + } + ) + + exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs( + agent_soul=agent_soul, + node_job=node_job, + ) + + assert exact_keys == {"files/source.pdf", "files/expected.pdf"} + assert prefixes == {"tender-analyzer/"} + + def test_composer_create_agents_syncs_active_config_has_model(monkeypatch: pytest.MonkeyPatch): fake_session = FakeSession() monkeypatch.setattr(composer_service.db, "session", fake_session) @@ -2060,6 +2556,97 @@ class TestListWorkflowsReferencingAppAgent: class TestWorkflowAgentDraftBindingSync: + def _agent_workflow(self) -> Workflow: + return Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + version=Workflow.VERSION_DRAFT, + graph=json.dumps( + { + "nodes": [{"id": "agent-node", "data": {"type": "agent", "version": "2"}}], + "edges": [], + } + ), + ) + + def _agent_binding(self) -> WorkflowAgentNodeBinding: + return WorkflowAgentNodeBinding( + id="binding-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + workflow_version=Workflow.VERSION_DRAFT, + node_id="agent-node", + binding_type=WorkflowAgentBindingType.ROSTER_AGENT, + agent_id="agent-1", + current_snapshot_id="snapshot-1", + node_job_config=WorkflowNodeJobConfig(), + ) + + def _publish_agent(self) -> Agent: + return Agent( + id="agent-1", + tenant_id="tenant-1", + name="Iris", + status=AgentStatus.ACTIVE, + active_config_snapshot_id="snapshot-1", + ) + + def _snapshot(self, agent_soul: AgentSoulConfig) -> AgentConfigSnapshot: + return AgentConfigSnapshot( + id="snapshot-1", + tenant_id="tenant-1", + agent_id="agent-1", + version=1, + config_snapshot=agent_soul, + ) + + def test_publish_validation_rejects_agent_soul_publish_only_errors(self): + binding = self._agent_binding() + agent_soul = AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + }, + "prompt": {"system_prompt": "no human reference yet"}, + "human": {"contacts": [{"id": "human-1", "name": "Reviewer"}]}, + } + ) + agent = self._publish_agent() + snapshot = self._snapshot(agent_soul) + session = FakeSession(scalar=[binding, agent, snapshot, agent, snapshot], scalars=[[binding]]) + + with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"): + WorkflowAgentPublishService.validate_agent_nodes_for_publish( + session=session, + draft_workflow=self._agent_workflow(), + ) + + def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self): + binding = self._agent_binding() + agent_soul = AgentSoulConfig.model_validate( + { + "model": { + "plugin_id": "langgenius/openai/openai", + "model_provider": "openai", + "model": "gpt-4o", + }, + "prompt": {"system_prompt": "Use [§skill:research%2FSKILL.md:Research§]."}, + } + ) + agent = self._publish_agent() + snapshot = self._snapshot(agent_soul) + session = FakeSession(scalar=[binding, agent, snapshot, agent, snapshot], scalars=[[binding], []]) + + with pytest.raises(WorkflowAgentNodeValidationError, match="skill_ref_dangling"): + WorkflowAgentPublishService.validate_agent_nodes_for_publish( + session=session, + draft_workflow=self._agent_workflow(), + ) + def test_projects_binding_declared_outputs_to_draft_graph_response(self): workflow = Workflow( id="workflow-1", diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index b43c01778eb..5dc68008840 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -745,15 +745,54 @@ class TestMemberRoles: def test_replace(self, mock_send: MagicMock): mock_send.return_value = {"account_id": "acct-2", "roles": []} - svc.RBACService.MemberRoles.replace( - "tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"] - ) + with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True): + svc.RBACService.MemberRoles.replace( + "tenant-1", "acct-1", "acct-2", role_ids=["workspace.owner", "workspace.editor"] + ) call = _call_args(mock_send) assert call.method == "PUT" assert call.endpoint == "/rbac/members/rbac-roles" assert call.params == {"account_id": "acct-2"} assert call.json == {"role_ids": ["workspace.owner", "workspace.editor"]} + def test_replace_updates_legacy_join_role_when_rbac_disabled(self, mock_send: MagicMock): + session = MagicMock() + session.__enter__.return_value = session + target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2") + session.scalar.return_value = target_join + + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=session), + ): + out = svc.RBACService.MemberRoles.replace("tenant-1", "acct-1", "acct-2", role_ids=["editor"]) + + mock_send.assert_not_called() + session.commit.assert_called_once() + assert target_join.role == svc.TenantAccountRole.EDITOR + assert out.account_id == "acct-2" + assert out.roles[0].id == "editor" + assert "app.acl.preview" in out.roles[0].permission_keys + + def test_replace_legacy_owner_demotes_current_owner_when_rbac_disabled(self, mock_send: MagicMock): + session = MagicMock() + session.__enter__.return_value = session + target_join = SimpleNamespace(role=svc.TenantAccountRole.NORMAL, account_id="acct-2") + owner_join = SimpleNamespace(role=svc.TenantAccountRole.OWNER, account_id="acct-owner") + session.scalar.side_effect = [target_join, owner_join] + + with ( + patch(f"{MODULE}.dify_config.RBAC_ENABLED", False), + patch(f"{MODULE}.session_factory.create_session", return_value=session), + ): + out = svc.RBACService.MemberRoles.replace("tenant-1", "acct-1", "acct-2", role_ids=["owner"]) + + mock_send.assert_not_called() + session.commit.assert_called_once() + assert target_join.role == svc.TenantAccountRole.OWNER + assert owner_join.role == svc.TenantAccountRole.ADMIN + assert out.roles[0].id == "owner" + def test_batch_get(self, mock_send: MagicMock): mock_send.return_value = { "acct-2": [ diff --git a/api/uv.lock b/api/uv.lock index 6e998f6f6ed..6ad4fa5cff1 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1636,7 +1636,7 @@ requires-dist = [ { name = "gmpy2", specifier = ">=2.3.0,<3.0.0" }, { name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" }, { name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" }, - { name = "graphon", specifier = "==0.5.2" }, + { name = "graphon", specifier = "==0.5.3" }, { name = "gunicorn", specifier = ">=26.0.0,<27.0.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "httpx-sse", specifier = "==0.4.3" }, @@ -2987,7 +2987,7 @@ httpx = [ [[package]] name = "graphon" -version = "0.5.2" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, @@ -3008,9 +3008,9 @@ dependencies = [ { name = "unstructured", extra = ["docx", "epub", "md", "ppt", "pptx"] }, { name = "webvtt-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/16/f183da187414c335be67f52f6a1b7c2a33bf0b1d5090eda7e6c92d42d94a/graphon-0.5.2.tar.gz", hash = "sha256:d66a9edcd883766bd50e94f84a691c92ce536ea60e721552089e83ac8e94bf68", size = 269773, upload-time = "2026-06-16T04:06:22.074Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/02/75c8cc2f946c8b6debe4f71a8a0f41a69cd499073368a8735ca424c6551f/graphon-0.5.3.tar.gz", hash = "sha256:eaa87d5e664acdf14c80e38afce6bc0f14644961de7ce7b059266fe61bc30e0b", size = 271204, upload-time = "2026-06-23T08:13:32.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/e6/36a3981cd44e7a40a7cd7d374e26f01e02dd49410c5fbbd7df248750d5fb/graphon-0.5.2-py3-none-any.whl", hash = "sha256:11f89399e67ed1ddd2ce1c336accd9c4ad5b8fe2741f9167e6085af0b325cd14", size = 381908, upload-time = "2026-06-16T04:06:20.453Z" }, + { url = "https://files.pythonhosted.org/packages/84/fb/616f8ecbd184af57dca8380877b149198d944f4a6658cceb353ae02ace92/graphon-0.5.3-py3-none-any.whl", hash = "sha256:a7f070d1e5eef13d25b97cce6d23675b228c1d38f3c656e3dcacaa6be9ccada4", size = 383359, upload-time = "2026-06-23T08:13:31.075Z" }, ] [[package]] diff --git a/dify-agent/src/dify_agent/layers/shell/layer.py b/dify-agent/src/dify_agent/layers/shell/layer.py index 5db17d68499..a8f46d628a6 100644 --- a/dify-agent/src/dify_agent/layers/shell/layer.py +++ b/dify-agent/src/dify_agent/layers/shell/layer.py @@ -18,9 +18,11 @@ side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs, Agenton still exits ``resource_context()`` but never transitions the layer to ``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run, so the enter hook itself must perform best-effort business compensation before -re-raising the failure. Agent Stub env injection uses shellctl's native per-run -``env`` argument for user-visible ``shell.run`` and for trusted server-owned -fixed scripts executed through ``run_remote_script()``. +re-raising the failure. Agent Soul shell env is injected into user-visible +commands and CLI bootstrap commands without persisting a workspace env file. +Agent Stub env injection uses shellctl's native per-run ``env`` argument for +user-visible ``shell.run`` and for trusted server-owned fixed scripts executed +through ``run_remote_script()``. """ from __future__ import annotations @@ -475,7 +477,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC try: client = self._require_client() result = await client.run( - _wrap_user_script(script), + _wrap_user_script(script, self.config), cwd=self._require_workspace_cwd(), env=self._build_user_shell_run_env(), timeout=timeout, @@ -536,9 +538,9 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC and optional Agent Stub env injection. Unlike model-visible ``shell.run``, this server-owned boundary does not - source ``.dify/env.sh``. That file is user-controlled shell config, so - sourcing it here would let sandbox code clobber trusted Agent Stub env - values before ``dify-agent file upload`` executes. + inject Agent Soul shell env. Keeping the user-controlled shell env out + of this path prevents sandbox code from clobbering trusted Agent Stub + env values before ``dify-agent file upload`` executes. """ env = None if inject_agent_stub_env: @@ -833,16 +835,18 @@ def _workspace_cwd(session_id: str) -> str: def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: - """Return the workspace bootstrap script for env + CLI tool declarations.""" - has_bootstrap = bool(config.env or config.secret_refs or config.cli_tools or config.sandbox is not None) - if not has_bootstrap: + """Return the workspace bootstrap script for CLI tool declarations.""" + install_commands = [command for tool in config.cli_tools for command in tool.install_commands] + if not install_commands: return "" - lines: list[str] = [ - "set -eu", - 'mkdir -p ".dify"', - "cat > \".dify/env.sh\" <<'DIFY_ENV_EOF'", - ] + lines: list[str] = ["set -eu", *_shell_config_export_lines(config), *install_commands] + return "\n".join(lines) + + +def _shell_config_export_lines(config: DifyShellLayerConfig) -> list[str]: + """Return ephemeral Agent Soul shell exports for one shellctl command.""" + lines: list[str] = [] for env_var in config.env: lines.append(f"export {env_var.name}={_shquote(env_var.value)}") for secret_ref in config.secret_refs: @@ -860,32 +864,15 @@ def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str: if config.sandbox.config: sandbox_config = json.dumps(config.sandbox.config, ensure_ascii=True, sort_keys=True) lines.append(f"export DIFY_SANDBOX_CONFIG_JSON={_shquote(sandbox_config)}") - lines.extend( - [ - "DIFY_ENV_EOF", - 'chmod 600 ".dify/env.sh"', - '. ".dify/env.sh"', - ] - ) - for tool in config.cli_tools: - for command in tool.install_commands: - lines.append(command) - return "\n".join(lines) + return lines -def _wrap_user_script(script: str) -> str: - """Source Agent Soul env before executing a model-requested shell command.""" - # TODO: refactor - return "\n".join( - [ - 'if [ -f ".dify/env.sh" ]; then', - " set -a", - ' . ".dify/env.sh"', - " set +a", - "fi", - script, - ] - ) +def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str: + """Inject Agent Soul env before executing a model-requested shell command.""" + lines = _shell_config_export_lines(config) + if not lines: + return script + return "\n".join([*lines, script]) def _workspace_mkdir_script(*, session_id: str) -> str: diff --git a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py index 30352d87c5e..c7d2599b63c 100644 --- a/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/shell/test_layer.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Mapping import secrets import time from dataclasses import dataclass +from typing import cast import pytest @@ -454,7 +455,6 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte assert 'export GITHUB_TOKEN="${GITHUB_TOKEN:-}"' in script assert "export DIFY_SANDBOX_PROVIDER='independent'" in script assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script - assert '. ".dify/env.sh"' in script assert "apt-get install -y ripgrep" in script return _job_result("bootstrap-job", status=JobStatusName.EXITED, done=True, exit_code=0) @@ -489,10 +489,60 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"] +def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(time, "time", lambda: 0xABC12) + + def token_hex(_nbytes: int) -> str: + return "ff" + + monkeypatch.setattr(secrets, "token_hex", token_hex) + + def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: + del timeout + assert env is None + if cwd is None: + return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0) + + assert cwd == "~/workspace/abc12ff" + assert "export PROJECT_NAME='demo project'" in script + assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script + assert "export DIFY_SANDBOX_PROVIDER='independent'" in script + assert "export DIFY_SANDBOX_CONFIG_JSON='{\"cpu\": 2}'" in script + assert script.endswith("\npwd") + return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0) + + client = FakeShellctlClient(run_handler=run_handler) + layer = _shell_layer( + client_factory=lambda _entrypoint: client, + config=DifyShellLayerConfig( + env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")], + secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")], + sandbox=DifyShellSandboxConfig(provider="independent", config={"cpu": 2}), + ), + ) + tools = {tool.name: tool for tool in layer.tools} + + async def scenario() -> None: + async with layer.resource_context(): + await layer.on_context_create() + run_result = cast( + Mapping[str, object], + await tools["shell_run"].function_schema.call( + {"script": "pwd"}, + None, # pyright: ignore[reportArgumentType] + ), + ) + assert run_result["job_id"] == "user-job" + + asyncio.run(scenario()) + + assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"] + assert layer.runtime_state.job_ids == ["mkdir-job", "user-job"] + + def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None: def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: - assert script.endswith("\npwd") - assert '. ".dify/env.sh"' in script + assert script == "pwd" assert cwd == "~/workspace/abc12ff" assert env is None assert timeout == 2.5 @@ -608,8 +658,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() - def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None: def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult: del cwd, timeout - if script.endswith("\npwd"): - assert '. ".dify/env.sh"' in script + if script == "pwd": assert env is not None return _job_result("user-job", status=JobStatusName.EXITED, done=True, exit_code=0) assert env is None @@ -639,8 +688,8 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> asyncio.run(scenario()) - user_run_call = next(call for call in client.run_calls if call.script.endswith("\npwd")) - internal_run_calls = [call for call in client.run_calls if not call.script.endswith("\npwd")] + user_run_call = next(call for call in client.run_calls if call.script == "pwd") + internal_run_calls = [call for call in client.run_calls if call.script != "pwd"] assert user_run_call.env == { AGENT_STUB_API_BASE_URL_ENV_VAR: "https://agent.example.com/agent-stub", diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a9975b4476e..5c1d04ee120 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2133,9 +2133,6 @@ } }, "web/app/components/base/markdown-blocks/think-block.tsx": { - "react/set-state-in-effect": { - "count": 1 - }, "ts/no-explicit-any": { "count": 4 } diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 4d4c83e1a71..27e1d3f3a81 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -134,9 +134,14 @@ export type ComposerSavePayload = { agent_soul?: AgentSoulConfig | null binding?: ComposerBindingPayload | null client_revision_id?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null idempotency_key?: string | null new_agent_name?: string | null node_job?: WorkflowNodeJobConfig | null + role?: string | null save_strategy: ComposerSaveStrategy soul_lock?: ComposerSoulLockPayload variant: ComposerVariant @@ -536,6 +541,8 @@ export type ComposerBindingPayload = { current_snapshot_id?: string | null } +export type AgentIconType = 'emoji' | 'image' | 'link' + export type WorkflowNodeJobConfig = { declared_outputs?: Array human_contacts?: Array @@ -876,8 +883,6 @@ export type LlmMode = 'chat' | 'completion' export type AgentKind = 'dify_agent' -export type AgentIconType = 'emoji' | 'image' | 'link' - export type AgentPublishedReferenceResponse = { app_icon?: string | null app_icon_background?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 43e4f61fc4e..1d6d03c735d 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -282,6 +282,13 @@ export const zComposerBindingPayload = z.object({ current_snapshot_id: z.string().nullish(), }) +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + /** * ComposerSoulLockPayload */ @@ -830,13 +837,6 @@ export const zAgentAppDetailWithSite = z.object({ */ export const zAgentKind = z.enum(['dify_agent']) -/** - * AgentIconType - * - * Supported icon storage formats for Agent roster entries. - */ -export const zAgentIconType = z.enum(['emoji', 'image', 'link']) - /** * AgentPublishedReferenceResponse */ @@ -1854,6 +1854,13 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'] /** * AgentKnowledgeQueryConfig + * + * Per-set query policy for Agent v2 knowledge retrieval. + * + * Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the + * legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each + * set owns its own query policy, so ``user_query`` must carry an explicit + * ``value`` while ``generated_query`` leaves that value empty. */ export const zAgentKnowledgeQueryConfig = z.object({ mode: zAgentKnowledgeQueryMode, @@ -1879,6 +1886,12 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({ /** * AgentKnowledgeRetrievalConfig + * + * Per-set retrieval policy for Agent v2 knowledge retrieval. + * + * Retrieval settings now live on each knowledge set instead of one shared + * flat config. A set may use either ``multiple`` retrieval with ``top_k`` or + * ``single`` retrieval with a required model config. */ export const zAgentKnowledgeRetrievalConfig = z.object({ mode: z.enum(['multiple', 'single']), @@ -1967,6 +1980,12 @@ export const zAgentKnowledgeMetadataConditions = z.object({ /** * AgentKnowledgeMetadataFilteringConfig + * + * Per-set metadata filtering policy. + * + * The Python attribute uses ``metadata_model_config`` for clarity because the + * model belongs to metadata filtering specifically, while the external API and + * generated schema keep the historical ``model_config`` field name via alias. */ export const zAgentKnowledgeMetadataFilteringConfig = z.object({ conditions: zAgentKnowledgeMetadataConditions.nullish(), @@ -1976,6 +1995,13 @@ export const zAgentKnowledgeMetadataFilteringConfig = z.object({ /** * AgentKnowledgeSetConfig + * + * One explicit knowledge set in Agent v2. + * + * ``knowledge.sets`` replaces the old flat knowledge config. Each set owns + * its datasets plus query, retrieval, and metadata policies. An individual + * set must contain at least one dataset id even though the overall knowledge + * section may be empty, which is how callers express "no knowledge layer". */ export const zAgentKnowledgeSetConfig = z.object({ datasets: z.array(zAgentKnowledgeDatasetConfig), @@ -1989,6 +2015,14 @@ export const zAgentKnowledgeSetConfig = z.object({ /** * AgentSoulKnowledgeConfig + * + * Top-level Agent v2 knowledge config. + * + * Agent v2 models knowledge as explicit sets instead of one flat + * ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` + * list means no knowledge layer should be emitted at runtime, while set-name + * uniqueness stays case-insensitive because runtime selection addresses sets + * by name. */ export const zAgentSoulKnowledgeConfig = z.object({ sets: z.array(zAgentKnowledgeSetConfig).optional(), @@ -2031,9 +2065,14 @@ export const zComposerSavePayload = z.object({ agent_soul: zAgentSoulConfig.nullish(), binding: zComposerBindingPayload.nullish(), client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), idempotency_key: z.string().nullish(), new_agent_name: z.string().min(1).max(255).nullish(), node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), save_strategy: zComposerSaveStrategy, soul_lock: zComposerSoulLockPayload.optional(), variant: zComposerVariant, diff --git a/packages/contracts/generated/api/console/apps/orpc.gen.ts b/packages/contracts/generated/api/console/apps/orpc.gen.ts index 7a93572885e..ea72df28458 100644 --- a/packages/contracts/generated/api/console/apps/orpc.gen.ts +++ b/packages/contracts/generated/api/console/apps/orpc.gen.ts @@ -392,6 +392,9 @@ import { zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunBody, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunPath, zPostAppsByAppIdWorkflowsDraftLoopNodesByNodeIdRunResponse, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactPath, zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse, @@ -3479,6 +3482,26 @@ export const candidates = { } export const post51 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRoster', + path: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster', + tags: ['console'], + }) + .input( + z.object({ + body: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody, + params: zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath, + }), + ) + .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse) + +export const copyFromRoster = { + post: post51, +} + +export const post52 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3495,10 +3518,10 @@ export const post51 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactResponse) export const impact = { - post: post51, + post: post52, } -export const post52 = oc +export const post53 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3515,10 +3538,10 @@ export const post52 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerSaveToRosterResponse) export const saveToRoster = { - post: post52, + post: post53, } -export const post53 = oc +export const post54 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3535,7 +3558,7 @@ export const post53 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerValidateResponse) export const validate = { - post: post53, + post: post54, } export const get62 = oc @@ -3569,6 +3592,7 @@ export const agentComposer = { get: get62, put: put4, candidates, + copyFromRoster, impact, saveToRoster, validate, @@ -3598,7 +3622,7 @@ export const lastRun = { * * Run draft workflow node */ -export const post54 = oc +export const post55 = oc .route({ description: 'Run draft workflow node', inputStructure: 'detailed', @@ -3617,7 +3641,7 @@ export const post54 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdRunResponse) export const run8 = { - post: post54, + post: post55, } /** @@ -3625,7 +3649,7 @@ export const run8 = { * * Poll for trigger events and execute single node when event arrives */ -export const post55 = oc +export const post56 = oc .route({ description: 'Poll for trigger events and execute single node when event arrives', inputStructure: 'detailed', @@ -3639,7 +3663,7 @@ export const post55 = oc .output(zPostAppsByAppIdWorkflowsDraftNodesByNodeIdTriggerRunResponse) export const run9 = { - post: post55, + post: post56, } export const trigger = { @@ -3699,7 +3723,7 @@ export const nodes7 = { * * Run draft workflow */ -export const post56 = oc +export const post57 = oc .route({ description: 'Run draft workflow', inputStructure: 'detailed', @@ -3718,7 +3742,7 @@ export const post56 = oc .output(zPostAppsByAppIdWorkflowsDraftRunResponse) export const run10 = { - post: post56, + post: post57, } /** @@ -3840,7 +3864,7 @@ export const systemVariables = { * * Poll for trigger events and execute full workflow when event arrives */ -export const post57 = oc +export const post58 = oc .route({ description: 'Poll for trigger events and execute full workflow when event arrives', inputStructure: 'detailed', @@ -3859,7 +3883,7 @@ export const post57 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunResponse) export const run11 = { - post: post57, + post: post58, } /** @@ -3867,7 +3891,7 @@ export const run11 = { * * Full workflow debug when the start node is a trigger */ -export const post58 = oc +export const post59 = oc .route({ description: 'Full workflow debug when the start node is a trigger', inputStructure: 'detailed', @@ -3886,7 +3910,7 @@ export const post58 = oc .output(zPostAppsByAppIdWorkflowsDraftTriggerRunAllResponse) export const runAll = { - post: post58, + post: post59, } export const trigger2 = { @@ -4039,7 +4063,7 @@ export const get72 = oc * * Sync draft workflow configuration */ -export const post59 = oc +export const post60 = oc .route({ description: 'Sync draft workflow configuration', inputStructure: 'detailed', @@ -4059,7 +4083,7 @@ export const post59 = oc export const draft2 = { get: get72, - post: post59, + post: post60, conversationVariables: conversationVariables2, environmentVariables, features, @@ -4095,7 +4119,7 @@ export const get73 = oc /** * Publish workflow */ -export const post60 = oc +export const post61 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -4114,7 +4138,7 @@ export const post60 = oc export const publish = { get: get73, - post: post60, + post: post61, } /** @@ -4251,7 +4275,7 @@ export const triggers2 = { /** * Restore a published workflow version into the draft workflow */ -export const post61 = oc +export const post62 = oc .route({ description: 'Restore a published workflow version into the draft workflow', inputStructure: 'detailed', @@ -4264,7 +4288,7 @@ export const post61 = oc .output(zPostAppsByAppIdWorkflowsByWorkflowIdRestoreResponse) export const restore = { - post: post61, + post: post62, } /** @@ -4489,7 +4513,7 @@ export const get81 = oc * * Create a new API key for an app */ -export const post62 = oc +export const post63 = oc .route({ description: 'Create a new API key for an app', inputStructure: 'detailed', @@ -4505,7 +4529,7 @@ export const post62 = oc export const apiKeys = { get: get81, - post: post62, + post: post63, byApiKeyId, } @@ -4563,7 +4587,7 @@ export const get83 = oc * * Create a new application */ -export const post63 = oc +export const post64 = oc .route({ description: 'Create a new application', inputStructure: 'detailed', @@ -4579,7 +4603,7 @@ export const post63 = oc export const apps = { get: get83, - post: post63, + post: post64, imports, starred, workflows, diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 9b31296275b..e61dc4a4179 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -986,9 +986,14 @@ export type ComposerSavePayload = { agent_soul?: AgentSoulConfig | null binding?: ComposerBindingPayload | null client_revision_id?: string | null + description?: string | null + icon?: string | null + icon_background?: string | null + icon_type?: AgentIconType | null idempotency_key?: string | null new_agent_name?: string | null node_job?: WorkflowNodeJobConfig | null + role?: string | null save_strategy: ComposerSaveStrategy soul_lock?: ComposerSoulLockPayload variant: ComposerVariant @@ -1003,6 +1008,12 @@ export type AgentComposerCandidatesResponse = { variant: ComposerVariant } +export type WorkflowComposerCopyFromRosterPayload = { + idempotency_key?: string | null + source_agent_id: string + source_snapshot_id?: string | null +} + export type AgentComposerImpactResponse = { bindings?: Array current_snapshot_id?: string | null @@ -1873,6 +1884,8 @@ export type ComposerBindingPayload = { current_snapshot_id?: string | null } +export type AgentIconType = 'emoji' | 'image' | 'link' + export type ComposerSoulLockPayload = { locked?: boolean unlocked_from_version_id?: string | null @@ -5505,6 +5518,23 @@ export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResp export type GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses[keyof GetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponses] +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterData = { + body: WorkflowComposerCopyFromRosterPayload + path: { + app_id: string + node_id: string + } + query?: never + url: '/apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/copy-from-roster' +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses = { + 200: WorkflowAgentComposerResponse +} + +export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse + = PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses[keyof PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponses] + export type PostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactData = { body: ComposerSavePayload path: { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index b3c0f05bf6b..c99c6be0c0c 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -642,6 +642,15 @@ export const zHumanInputDeliveryTestPayload = z.object({ */ export const zEmptyObjectResponse = z.record(z.string(), z.unknown()) +/** + * WorkflowComposerCopyFromRosterPayload + */ +export const zWorkflowComposerCopyFromRosterPayload = z.object({ + idempotency_key: z.string().max(255).nullish(), + source_agent_id: z.string().min(1).max(255), + source_snapshot_id: z.string().max(255).nullish(), +}) + /** * DraftWorkflowNodeRunPayload */ @@ -1835,6 +1844,13 @@ export const zComposerBindingPayload = z.object({ current_snapshot_id: z.string().nullish(), }) +/** + * AgentIconType + * + * Supported icon storage formats for Agent roster entries. + */ +export const zAgentIconType = z.enum(['emoji', 'image', 'link']) + /** * ComposerSoulLockPayload */ @@ -3305,6 +3321,13 @@ export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'] /** * AgentKnowledgeQueryConfig + * + * Per-set query policy for Agent v2 knowledge retrieval. + * + * Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the + * legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each + * set owns its own query policy, so ``user_query`` must carry an explicit + * ``value`` while ``generated_query`` leaves that value empty. */ export const zAgentKnowledgeQueryConfig = z.object({ mode: zAgentKnowledgeQueryMode, @@ -3330,6 +3353,12 @@ export const zAgentKnowledgeWeightedScoreConfig = z.object({ /** * AgentKnowledgeRetrievalConfig + * + * Per-set retrieval policy for Agent v2 knowledge retrieval. + * + * Retrieval settings now live on each knowledge set instead of one shared + * flat config. A set may use either ``multiple`` retrieval with ``top_k`` or + * ``single`` retrieval with a required model config. */ export const zAgentKnowledgeRetrievalConfig = z.object({ mode: z.enum(['multiple', 'single']), @@ -3501,6 +3530,12 @@ export const zAgentKnowledgeMetadataConditions = z.object({ /** * AgentKnowledgeMetadataFilteringConfig + * + * Per-set metadata filtering policy. + * + * The Python attribute uses ``metadata_model_config`` for clarity because the + * model belongs to metadata filtering specifically, while the external API and + * generated schema keep the historical ``model_config`` field name via alias. */ export const zAgentKnowledgeMetadataFilteringConfig = z.object({ conditions: zAgentKnowledgeMetadataConditions.nullish(), @@ -3510,6 +3545,13 @@ export const zAgentKnowledgeMetadataFilteringConfig = z.object({ /** * AgentKnowledgeSetConfig + * + * One explicit knowledge set in Agent v2. + * + * ``knowledge.sets`` replaces the old flat knowledge config. Each set owns + * its datasets plus query, retrieval, and metadata policies. An individual + * set must contain at least one dataset id even though the overall knowledge + * section may be empty, which is how callers express "no knowledge layer". */ export const zAgentKnowledgeSetConfig = z.object({ datasets: z.array(zAgentKnowledgeDatasetConfig), @@ -3523,6 +3565,14 @@ export const zAgentKnowledgeSetConfig = z.object({ /** * AgentSoulKnowledgeConfig + * + * Top-level Agent v2 knowledge config. + * + * Agent v2 models knowledge as explicit sets instead of one flat + * ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets`` + * list means no knowledge layer should be emitted at runtime, while set-name + * uniqueness stays case-insensitive because runtime selection addresses sets + * by name. */ export const zAgentSoulKnowledgeConfig = z.object({ sets: z.array(zAgentKnowledgeSetConfig).optional(), @@ -3573,9 +3623,14 @@ export const zComposerSavePayload = z.object({ agent_soul: zAgentSoulConfig.nullish(), binding: zComposerBindingPayload.nullish(), client_revision_id: z.string().nullish(), + description: z.string().nullish(), + icon: z.string().max(255).nullish(), + icon_background: z.string().max(255).nullish(), + icon_type: zAgentIconType.nullish(), idempotency_key: z.string().nullish(), new_agent_name: z.string().min(1).max(255).nullish(), node_job: zWorkflowNodeJobConfig.nullish(), + role: z.string().max(255).nullish(), save_strategy: zComposerSaveStrategy, soul_lock: zComposerSoulLockPayload.optional(), variant: zComposerVariant, @@ -5458,6 +5513,20 @@ export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesPa export const zGetAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCandidatesResponse = zAgentComposerCandidatesResponse +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterBody + = zWorkflowComposerCopyFromRosterPayload + +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterPath = z.object({ + app_id: z.uuid(), + node_id: z.string(), +}) + +/** + * Workflow roster agent copied to inline agent + */ +export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerCopyFromRosterResponse + = zWorkflowAgentComposerResponse + export const zPostAppsByAppIdWorkflowsDraftNodesByNodeIdAgentComposerImpactBody = zComposerSavePayload 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 75cc4efa2c4..f9a5eb01edc 100644 --- a/packages/contracts/generated/api/console/installed-apps/types.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/types.gen.ts @@ -98,8 +98,8 @@ export type ResultResponse = { result: string } -export type MessageInfiniteScrollPagination = { - data: Array +export type ExploreMessageInfiniteScrollPagination = { + data: Array has_more: boolean limit: number } @@ -187,7 +187,7 @@ export type JsonValue | Array | null -export type MessageListItem = { +export type ExploreMessageListItem = { agent_thoughts: Array answer: string conversation_id: string @@ -200,6 +200,7 @@ export type MessageListItem = { [key: string]: JsonValueType } message_files: Array + metadata?: JsonValueType | null parent_message_id?: string | null query: string retriever_resources: Array @@ -644,7 +645,7 @@ export type GetInstalledAppsByInstalledAppIdMessagesData = { } export type GetInstalledAppsByInstalledAppIdMessagesResponses = { - 200: MessageInfiniteScrollPagination + 200: ExploreMessageInfiniteScrollPagination } export type GetInstalledAppsByInstalledAppIdMessagesResponse 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 a9a06fe3f7e..a4556058506 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -503,9 +503,9 @@ export const zHumanInputContent = z.object({ }) /** - * MessageListItem + * ExploreMessageListItem */ -export const zMessageListItem = z.object({ +export const zExploreMessageListItem = z.object({ agent_thoughts: z.array(zAgentThought), answer: z.string(), conversation_id: z.string(), @@ -516,6 +516,7 @@ export const zMessageListItem = z.object({ id: z.string(), inputs: z.record(z.string(), zJsonValueType), message_files: z.array(zMessageFile), + metadata: zJsonValueType.nullish(), parent_message_id: z.string().nullish(), query: z.string(), retriever_resources: z.array(zRetrieverResource), @@ -523,10 +524,10 @@ export const zMessageListItem = z.object({ }) /** - * MessageInfiniteScrollPagination + * ExploreMessageInfiniteScrollPagination */ -export const zMessageInfiniteScrollPagination = z.object({ - data: z.array(zMessageListItem), +export const zExploreMessageInfiniteScrollPagination = z.object({ + data: z.array(zExploreMessageListItem), has_more: z.boolean(), limit: z.int(), }) @@ -693,7 +694,8 @@ export const zGetInstalledAppsByInstalledAppIdMessagesQuery = z.object({ /** * Success */ -export const zGetInstalledAppsByInstalledAppIdMessagesResponse = zMessageInfiniteScrollPagination +export const zGetInstalledAppsByInstalledAppIdMessagesResponse + = zExploreMessageInfiniteScrollPagination export const zPostInstalledAppsByInstalledAppIdMessagesByMessageIdFeedbacksBody = zMessageFeedbackPayload diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx index b8088169fd5..eb522444778 100644 --- a/web/app/(commonLayout)/deployments/layout.tsx +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -1,13 +1,11 @@ import type { ReactNode } from 'react' import { DeployDrawer } from '@/features/deployments/deploy-drawer' -import { DeploymentsRouteStateHydrator } from '@/features/deployments/route-state-hydrator' export default function DeploymentsLayout({ children }: { children: ReactNode }) { return ( <> - {children} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 4d6bdf7984c..71baf83c795 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -7,6 +7,7 @@ import Zendesk from '@/app/components/base/zendesk' import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder' import { GotoAnything } from '@/app/components/goto-anything' import MainNavLayout from '@/app/components/main-nav/layout' +import { NextRouteStateBridge } from '@/app/components/next-route-state' import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics' import ReadmePanel from '@/app/components/plugins/readme-panel' import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount' @@ -26,24 +27,26 @@ export default async function Layout({ children }: { children: ReactNode }) { - - - - - - - {children} - - - - - - - - - - - + + + + + + + + {children} + + + + + + + + + + + + diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index 98840509e94..5341f2c5436 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -67,6 +67,8 @@ function getFormattedChatList(messages: any[]) { feedback: item.feedback, isAnswer: true, citation: item.retriever_resources, + reasoningContent: item.metadata?.reasoning, + reasoningFinished: true, message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))), parentMessageId: `question-${item.id}`, humanInputFormDataList, 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 c1b60420eb2..10624f27574 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -68,6 +68,7 @@ type HookCallbacks = { onWorkflowPaused: (workflowPaused: Record) => void onTTSChunk: (messageId: string, audio: string) => void onTTSEnd: (messageId: string, audio: string) => void + onReasoning: (chunk: { data: { message_id?: string, reasoning: string, node_id?: string, is_final?: boolean } }) => void } type UseChatFormSettings = NonNullable[1]> @@ -2410,4 +2411,98 @@ describe('useChat', () => { }) expect(result.current.chatList[1]!.annotation?.id).toBe('') }) + + describe('reasoning (separated mode)', () => { + it('accumulates reasoning deltas per node and marks finished on is_final (handleSend)', () => { + let callbacks: HookCallbacks + vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const { result } = renderHook(() => useChat()) + + act(() => { + result.current.handleSend('test-url', { query: 'hi' }, {}) + }) + act(() => { + callbacks.onData('answer', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' }) + }) + + act(() => { + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'let me ', node_id: 'llm' } }) + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'think', node_id: 'llm' } }) + }) + + const responseItem = result.current.chatList[1]! + expect(responseItem.reasoningContent).toEqual({ llm: 'let me think' }) + expect(responseItem.reasoningFinished).toBeUndefined() + // answer stays clean — reasoning never leaks into content + expect(responseItem.content).toBe('answer') + + act(() => { + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true } }) + }) + expect(result.current.chatList[1]!.reasoningContent).toEqual({ llm: 'let me think' }) + expect(result.current.chatList[1]!.reasoningFinished).toBe(true) + }) + + it('keys reasoning by node and falls back to "_" when node_id is absent (handleSend)', () => { + let callbacks: HookCallbacks + vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const { result } = renderHook(() => useChat()) + + act(() => { + result.current.handleSend('test-url', { query: 'hi' }, {}) + }) + act(() => { + callbacks.onData('answer', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' }) + }) + + act(() => { + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'a', node_id: 'llm-1' } }) + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'b', node_id: 'llm-2' } }) + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'c' } }) + }) + + expect(result.current.chatList[1]!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b', '_': 'c' }) + }) + + it('accumulates reasoning onto an existing answer node on resume (handleResume / sseGet)', () => { + let callbacks: HookCallbacks + vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => { + callbacks = options as HookCallbacks + }) + + const prevChatTree = [{ + id: 'q-1', + content: 'query', + isAnswer: false, + children: [{ + id: 'm-1', + content: 'initial', + isAnswer: true, + message_files: [], + siblingIndex: 0, + }], + }] + + const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[])) + + act(() => { + result.current.handleResume('m-1', 'wr-1', { isPublicAPI: true }) + }) + + act(() => { + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'resumed ', node_id: 'llm' } }) + callbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'thought', node_id: 'llm', is_final: true } }) + }) + + const responseItem = result.current.chatList.find(item => item.id === 'm-1')! + expect(responseItem.reasoningContent).toEqual({ llm: 'resumed thought' }) + expect(responseItem.reasoningFinished).toBe(true) + }) + }) }) 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 c5c5b163c8c..d5951fdd807 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 @@ -150,6 +150,92 @@ describe('Answer Component', () => { }) }) + // Reasoning panel slot (separated-mode chain-of-thought) in both layouts. + // The panel body renders through the async dynamic Markdown, so assertions + // target the synchronously-rendered "Thinking…/Thought" summary label. + describe('Reasoning Panel', () => { + it('should render the reasoning panel in the normal layout while thinking', () => { + render( + , + ) + expect(screen.getByText(/chat\.thinking/)).toBeInTheDocument() + }) + + it('should render the reasoning panel in the thought state once finished', () => { + render( + , + ) + expect(screen.getByText(/chat\.thought/)).toBeInTheDocument() + }) + + it('should render the reasoning panel within the human-input layout', () => { + render( + , + ) + // hasHumanInputs is true, so this can only come from the human-input slot + expect(screen.getByText(/chat\.(thinking|thought)/)).toBeInTheDocument() + }) + + it('should render the reasoning panel in the human-input layout when the answer is empty (history reload)', () => { + // Regression: the human-input slot outer guard must include hasReasoning, otherwise a + // rehydrated message with forms + reasoning but an empty answer drops the panel entirely. + render( + , + ) + expect(screen.getByText(/chat\.thought/)).toBeInTheDocument() + }) + + it('should not render the reasoning panel when reasoningContent is absent', () => { + render() + expect(screen.queryByText(/chat\.(thinking|thought)/)).not.toBeInTheDocument() + }) + + it('should not render the reasoning panel for an empty reasoningContent map (rehydrated, no reasoning)', () => { + render( + , + ) + expect(screen.queryByText(/chat\.(thinking|thought)/)).not.toBeInTheDocument() + }) + }) + describe('Interactions', () => { it('should handle switch sibling', () => { const mockSwitchSibling = vi.fn() diff --git a/web/app/components/base/chat/chat/answer/__tests__/reasoning-panel.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/reasoning-panel.spec.tsx new file mode 100644 index 00000000000..7d29250fefd --- /dev/null +++ b/web/app/components/base/chat/chat/answer/__tests__/reasoning-panel.spec.tsx @@ -0,0 +1,89 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import ReasoningPanel from '../reasoning-panel' + +// Mock react-i18next so the reused chat.thinking/chat.thought labels resolve. +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'chat.thinking': 'Thinking...', + 'chat.thought': 'Thought', + } + return translations[key] || key + }, + }), +})) + +// Mock the heavy Markdown renderer to a simple passthrough. +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + +describe('ReasoningPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders nothing when there is no reasoning text', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('shows the thinking state while not done', () => { + render() + expect(screen.getByText(/Thinking\.\.\./)).toBeInTheDocument() + expect(screen.getByText('let me think')).toBeInTheDocument() + }) + + it('shows the thought state once done (answer started / terminal / history)', () => { + render() + expect(screen.getByText(/Thought/)).toBeInTheDocument() + }) + + it('counts elapsed time up while thinking', () => { + render() + expect(screen.getByText(/\(0\.0s\)/)).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(screen.getByText(/\(0\.5s\)/)).toBeInTheDocument() + }) + + it('freezes the timer once done (latched), even if it flips back', () => { + const { rerender } = render() + act(() => { + vi.advanceTimersByTime(700) + }) + // Answer starts → done latches; timer must stop at 0.7s. + rerender() + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(screen.getByText(/Thought\(0\.7s\)/)).toBeInTheDocument() + }) + + it('concatenates reasoning from multiple LLM nodes', () => { + render() + expect(screen.getByTestId('reasoning-markdown')).toHaveTextContent('first second') + }) + + it('reflects in-place mutation of the same content object (streaming)', () => { + // The live stream mutates the same reasoningContent object under a stable reference, + // then re-renders. The panel must reflect the appended delta, not a stale snapshot. + const content: Record = { llm: 'first' } + const { rerender } = render() + expect(screen.getByTestId('reasoning-markdown')).toHaveTextContent('first') + + content.llm = 'first second' + rerender() + expect(screen.getByTestId('reasoning-markdown')).toHaveTextContent('first second') + }) +}) diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 36c99dd56a4..e46f80369a3 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -24,6 +24,7 @@ import HumanInputFilledFormList from './human-input-filled-form-list' import HumanInputFormList from './human-input-form-list' import More from './more' import Operation from './operation' +import ReasoningPanel from './reasoning-panel' import SuggestedQuestions from './suggested-questions' import WorkflowProcessItem from './workflow-process' @@ -74,6 +75,9 @@ const Answer: FC = ({ } = item const hasAgentThoughts = !!agent_thoughts?.length 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. + const hasReasoning = !!item.reasoningContent && Object.values(item.reasoningContent).some(Boolean) const [containerWidth, setContainerWidth] = useState(0) const [contentWidth, setContentWidth] = useState(0) @@ -140,6 +144,15 @@ const Answer: FC = ({ }, [switchSibling, item.prevSibling, item.nextSibling]) const contentIsEmpty = typeof content === 'string' && content.trim() === '' + // 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; + // ② the reasoning stream's terminal marker arrived (a reasoning node that finishes + // before a separate answer node starts); + // ③ the response is no longer active — explicitly false, not merely absent (history / abnormal end). + // graphon's is_final (on BOTH the text and reasoning channels) is a node-terminal marker + // that trails the whole answer, so it can't drive ①; the answer-started signal must. + const reasoningDone = !contentIsEmpty || !!item.reasoningFinished || responding === false return (
@@ -223,7 +236,7 @@ const Answer: FC = ({ )} {/* Block 2: Response Content (when human inputs exist) */} - {hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts) && ( + {hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts || hasReasoning) && (
= ({ ) } { - responding && contentIsEmpty && !hasAgentThoughts && ( + hasReasoning && ( + + ) + } + { + responding && contentIsEmpty && !hasAgentThoughts && !hasReasoning && (
@@ -351,7 +372,15 @@ const Answer: FC = ({ ) } { - responding && contentIsEmpty && !hasAgentThoughts && ( + hasReasoning && ( + + ) + } + { + responding && contentIsEmpty && !hasAgentThoughts && !hasReasoning && (
diff --git a/web/app/components/base/chat/chat/answer/reasoning-panel.tsx b/web/app/components/base/chat/chat/answer/reasoning-panel.tsx new file mode 100644 index 00000000000..a51f073f2e2 --- /dev/null +++ b/web/app/components/base/chat/chat/answer/reasoning-panel.tsx @@ -0,0 +1,31 @@ +import type { FC } from 'react' +import { Markdown } from '@/app/components/base/markdown' +import ThinkingDetails from '@/app/components/base/markdown-blocks/thinking-details' +import { useElapsedTimer } from '@/app/components/base/markdown-blocks/use-elapsed-timer' + +type ReasoningPanelProps = { + // reasoning (chain-of-thought) deltas accumulated per LLM node id + content: Record + // true once reasoning is over (answer started / terminal marker / response ended); + // latches the elapsed timer and collapses the panel. Computed by the caller. + done: boolean +} + +const ReasoningPanel: FC = ({ content, done }) => { + // First version renders one panel for the run; multiple LLM nodes are concatenated. + // Computed inline (not memoized): the live stream mutates `content` in place under a + // stable reference, so a [content]-keyed memo would never see new deltas. + const text = Object.values(content).filter(Boolean).join('\n\n') + const { elapsedTime, isComplete } = useElapsedTimer(done) + + if (!text) + return null + + return ( + + + + ) +} + +export default ReasoningPanel diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index db0ce1d1251..982c408fb63 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -12,6 +12,7 @@ import type { IOnDataMoreInfo, IOtherOptions, } from '@/service/base' +import type { ReasoningChunkResponse } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { uniqBy } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' @@ -283,6 +284,17 @@ export const useChat = ( if (taskId) taskIdRef.current = taskId }, + onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { + const { message_id, reasoning, node_id, is_final } = reasoningData + updateChatTreeNode(message_id, (responseItem) => { + const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) + const key = node_id || '_' + if (reasoning) + reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) + responseItem.reasoningFinished = true + }) + }, async onCompleted(hasError?: boolean) { handleResponding(false) @@ -757,6 +769,22 @@ export const useChat = ( parentId: data.parent_message_id, }) }, + onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { + const { reasoning, node_id, is_final } = reasoningData + const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) + const key = node_id || '_' + if (reasoning) + reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) + responseItem.reasoningFinished = true + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) + }, async onCompleted(hasError?: boolean) { handleResponding(false) diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts index 5f24c908383..167a6a3548b 100644 --- a/web/app/components/base/chat/chat/type.ts +++ b/web/app/components/base/chat/chat/type.ts @@ -113,6 +113,9 @@ export type IChatItem = { suggestedQuestions?: string[] log?: { role: string, text: string, files?: FileEntity[] }[] agent_thoughts?: ThoughtItem[] + // for LLM reasoning (chain-of-thought) in "separated" mode, keyed by LLM node id + reasoningContent?: Record + reasoningFinished?: boolean message_files?: FileEntity[] workflow_run_id?: string // for agent log diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 34abfad3489..c328cb76dfd 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -39,6 +39,8 @@ function getFormattedChatList(messages: any[]) { feedback: item.feedback, isAnswer: true, citation: item.retriever_resources, + reasoningContent: item.metadata?.reasoning, + reasoningFinished: true, message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))), parentMessageId: `question-${item.id}`, }) diff --git a/web/app/components/base/markdown-blocks/think-block.tsx b/web/app/components/base/markdown-blocks/think-block.tsx index b22caa6198c..2a72e4a38f7 100644 --- a/web/app/components/base/markdown-blocks/think-block.tsx +++ b/web/app/components/base/markdown-blocks/think-block.tsx @@ -1,8 +1,7 @@ -import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' import { useChatContext } from '../chat/chat/context' +import ThinkingDetails from './thinking-details' +import { useElapsedTimer } from './use-elapsed-timer' const hasEndThink = (children: any): boolean => { if (typeof children === 'string') @@ -40,34 +39,9 @@ const removeEndThink = (children: any): any => { const useThinkTimer = (children: any) => { const { isResponding } = useChatContext() const endThinkDetected = hasEndThink(children) - const [startTime] = useState(() => Date.now()) - const [elapsedTime, setElapsedTime] = useState(0) - const [isComplete, setIsComplete] = useState(() => endThinkDetected) - const timerRef = useRef(null) - - useEffect(() => { - if (isComplete) - return - - timerRef.current = setInterval(() => { - setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10) - }, 100) - - return () => { - if (timerRef.current) - clearInterval(timerRef.current) - } - }, [startTime, isComplete]) - - useEffect(() => { - // Stop timer when: - // 1. Content has [ENDTHINKFLAG] marker (normal completion) - // 2. isResponding is not true (false = user clicked stop, undefined = historical conversation) - if (endThinkDetected || !isResponding) - setIsComplete(true) - }, [endThinkDetected, isResponding]) - - return { elapsedTime, isComplete } + // Stop when the marker arrives (normal completion) or the response is no longer + // active (false = user stopped, undefined = historical conversation). + return useElapsedTimer(endThinkDetected || !isResponding) } type ThinkBlockProps = React.ComponentProps<'details'> & { @@ -77,41 +51,22 @@ type ThinkBlockProps = React.ComponentProps<'details'> & { const ThinkBlock = ({ children, ...props }: ThinkBlockProps) => { const { elapsedTime, isComplete } = useThinkTimer(children) const displayContent = removeEndThink(children) - const { t } = useTranslation() const { 'data-think': isThink = false, className, open, ...rest } = props if (!isThink) return (
{children}
) return ( -
- -
- - - - {isComplete ? `${t('chat.thought', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)` : `${t('chat.thinking', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)`} -
-
-
- {displayContent} -
-
+ {displayContent} + ) } diff --git a/web/app/components/base/markdown-blocks/thinking-details.tsx b/web/app/components/base/markdown-blocks/thinking-details.tsx new file mode 100644 index 00000000000..50fb6cbe66c --- /dev/null +++ b/web/app/components/base/markdown-blocks/thinking-details.tsx @@ -0,0 +1,49 @@ +import type { ComponentProps } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' + +type ThinkingDetailsProps = ComponentProps<'details'> & { + isComplete: boolean + elapsedTime: number +} + +/** + * Presentational collapsible "thinking" shell: the chevron summary with the + * "Thinking…/Thought (Xs)" label and the bordered content body. Driver-agnostic + * — callers compute `isComplete`/`elapsedTime` and pass the body as children. + */ +const ThinkingDetails = ({ isComplete, elapsedTime, className, open, children, ...rest }: ThinkingDetailsProps) => { + const { t } = useTranslation() + + return ( +
+ +
+ + + + {isComplete ? `${t('chat.thought', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)` : `${t('chat.thinking', { ns: 'common' })}(${elapsedTime.toFixed(1)}s)`} +
+
+
+ {children} +
+
+ ) +} + +export default ThinkingDetails diff --git a/web/app/components/base/markdown-blocks/use-elapsed-timer.ts b/web/app/components/base/markdown-blocks/use-elapsed-timer.ts new file mode 100644 index 00000000000..9098d1c1a3a --- /dev/null +++ b/web/app/components/base/markdown-blocks/use-elapsed-timer.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * Elapsed-time timer shared by the tagged-markdown `ThinkBlock` and the + * stream-driven `ReasoningPanel`. Counts up every 100ms until `complete` + * latches true, then freezes. Initializing complete=true (e.g. historical + * conversations) never starts the timer. + */ +export const useElapsedTimer = (complete: boolean) => { + const [startTime] = useState(() => Date.now()) + const [elapsedTime, setElapsedTime] = useState(0) + // Latch completion so a transient flip back to "not complete" never restarts the timer. + const completedRef = useRef(complete) + if (complete) + completedRef.current = true + const isComplete = completedRef.current + const timerRef = useRef(null) + + useEffect(() => { + if (isComplete) + return + + timerRef.current = setInterval(() => { + setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10) + }, 100) + + return () => { + if (timerRef.current) + clearInterval(timerRef.current) + } + }, [startTime, isComplete]) + + return { elapsedTime, isComplete } +} diff --git a/web/app/components/next-route-state/atoms.ts b/web/app/components/next-route-state/atoms.ts new file mode 100644 index 00000000000..0378415452c --- /dev/null +++ b/web/app/components/next-route-state/atoms.ts @@ -0,0 +1,51 @@ +import { atom } from 'jotai' + +export type NextRouteParams = Record + +type NextRouteState = { + pathname: string + params: NextRouteParams +} + +// Mirrors Next router state. NextRouteStateBridge force-hydrates this atom on +// render so feature atoms can read route state without calling router hooks. +const nextRouteStateAtom = atom({ + pathname: '', + params: {}, +}) + +function normalizedParamEntries(params: NextRouteParams) { + return Object.keys(params) + .sort() + .map((key) => { + const value = params[key] + return [key, Array.isArray(value) ? [...value] : value] as const + }) +} + +function normalizeNextRouteParams(params: NextRouteParams): NextRouteParams { + return Object.fromEntries(normalizedParamEntries(params)) as NextRouteParams +} + +function routeParamsKey(params: NextRouteParams) { + return JSON.stringify(normalizedParamEntries(params)) +} + +export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname) + +export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params) + +export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => { + const nextParams = normalizeNextRouteParams(routeState.params) + const currentRouteState = get(nextRouteStateAtom) + + if ( + currentRouteState.pathname !== routeState.pathname + || routeParamsKey(currentRouteState.params) !== routeParamsKey(nextParams) + ) { + set(nextRouteStateAtom, { + pathname: routeState.pathname, + params: nextParams, + }) + } +}) diff --git a/web/app/components/next-route-state/index.tsx b/web/app/components/next-route-state/index.tsx new file mode 100644 index 00000000000..cc0bec4f4cb --- /dev/null +++ b/web/app/components/next-route-state/index.tsx @@ -0,0 +1,25 @@ +'use client' + +import type { ReactNode } from 'react' +import type { NextRouteParams } from './atoms' +import { useHydrateAtoms } from 'jotai/utils' +import { useParams, usePathname } from '@/next/navigation' +import { + setNextRouteStateAtom, +} from './atoms' + +export function NextRouteStateBridge({ children }: { + children: ReactNode +}) { + const pathname = usePathname() + const params = useParams() + + useHydrateAtoms([ + [setNextRouteStateAtom, { + pathname, + params, + }], + ] as const, { dangerouslyForceHydrate: true }) + + return children +} diff --git a/web/app/components/workflow/panel/chat-record/index.tsx b/web/app/components/workflow/panel/chat-record/index.tsx index 3cf05ee9546..cae82e0f4e7 100644 --- a/web/app/components/workflow/panel/chat-record/index.tsx +++ b/web/app/components/workflow/panel/chat-record/index.tsx @@ -39,6 +39,8 @@ function getFormattedChatList(messages: any[]) { feedback: item.feedback, isAnswer: true, citation: item.metadata?.retriever_resources, + reasoningContent: item.metadata?.reasoning, + reasoningFinished: true, message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))), workflow_run_id: item.workflow_run_id, parentMessageId: `question-${item.id}`, diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts index 603db4df80a..e9fe31a909f 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts @@ -295,6 +295,35 @@ describe('useChat – handleResume', () => { }) }) + describe('onReasoning', () => { + it('should accumulate reasoning per node onto the resumed answer', async () => { + const { result } = await setupResumeWithTree() + + act(() => { + capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'resumed ', node_id: 'llm' } }) + capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'thought', node_id: 'llm', is_final: true } }) + }) + + const answer = result.current.chatList.find(item => item.id === 'msg-resume') + expect(answer!.reasoningContent).toEqual({ llm: 'resumed thought' }) + expect(answer!.reasoningFinished).toBe(true) + }) + + it('should ignore empty reasoning and fall back to "_" when node_id is absent', async () => { + const { result } = await setupResumeWithTree() + + act(() => { + capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: '' } }) + capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'a' } }) + capturedResumeOptions.onReasoning({ data: { message_id: 'msg-resume', reasoning: 'b' } }) + }) + + const answer = result.current.chatList.find(item => item.id === 'msg-resume') + expect(answer!.reasoningContent).toEqual({ _: 'ab' }) + expect(answer!.reasoningFinished).toBeUndefined() + }) + }) + describe('onCompleted', () => { it('should set isResponding to false', async () => { const { result } = await setupResumeWithTree() diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts index a67165fba64..1b1c7659fd2 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts @@ -216,6 +216,50 @@ describe('useChat – handleSend SSE callbacks', () => { }) }) + describe('onReasoning', () => { + const findAnswer = (result: any) => + result.current.chatList.find((item: any) => item.isAnswer && !item.isOpeningStatement) + + it('should accumulate reasoning per node without leaking into content', () => { + const { result } = setupAndSend() + + act(() => { + capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'let me ', node_id: 'llm' } }) + capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'think', node_id: 'llm' } }) + }) + + const answer = findAnswer(result) + expect(answer!.reasoningContent).toEqual({ llm: 'let me think' }) + expect(answer!.reasoningFinished).toBeUndefined() + expect(answer!.content).toBe('') + }) + + it('should key reasoning by node and fall back to "_" when node_id is absent', () => { + const { result } = setupAndSend() + + act(() => { + capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'a', node_id: 'llm-1' } }) + capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'b', node_id: 'llm-2' } }) + capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'c' } }) + }) + + expect(findAnswer(result)!.reasoningContent).toEqual({ 'llm-1': 'a', 'llm-2': 'b', '_': 'c' }) + }) + + it('should ignore empty reasoning and mark finished when is_final is set', () => { + const { result } = setupAndSend() + + act(() => { + capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: 'done', node_id: 'llm' } }) + capturedCallbacks.onReasoning({ data: { message_id: 'm-1', reasoning: '', node_id: 'llm', is_final: true } }) + }) + + const answer = findAnswer(result) + expect(answer!.reasoningContent).toEqual({ llm: 'done' }) + expect(answer!.reasoningFinished).toBe(true) + }) + }) + describe('onCompleted', () => { it('should set isResponding to false', async () => { const { result } = setupAndSend() diff --git a/web/app/components/workflow/panel/debug-and-preview/hooks.ts b/web/app/components/workflow/panel/debug-and-preview/hooks.ts index 463eff19221..d20a3311365 100644 --- a/web/app/components/workflow/panel/debug-and-preview/hooks.ts +++ b/web/app/components/workflow/panel/debug-and-preview/hooks.ts @@ -7,6 +7,7 @@ import type { } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { IOtherOptions } from '@/service/base' +import type { ReasoningChunkResponse } from '@/types/workflow' import { toast } from '@langgenius/dify-ui/toast' import { uniqBy } from 'es-toolkit/compat' import { produce, setAutoFreeze } from 'immer' @@ -365,6 +366,22 @@ export const useChat = ( parentId: params.parent_message_id, }) }, + onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { + const { reasoning, node_id, is_final } = reasoningData + const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) + const key = node_id || '_' + if (reasoning) + reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) + responseItem.reasoningFinished = true + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) + }, async onCompleted(hasError?: boolean, errorMessage?: string) { const { workflowRunningData } = workflowStore.getState() handleResponding(false) @@ -711,6 +728,18 @@ export const useChat = ( if (taskId) taskIdRef.current = taskId }, + onReasoning: ({ data: reasoningData }: ReasoningChunkResponse) => { + const { message_id, reasoning, node_id, is_final } = reasoningData + updateChatTreeNode(message_id, (responseItem) => { + const reasoningContent = responseItem.reasoningContent || (responseItem.reasoningContent = {}) + const key = node_id || '_' + if (reasoning) + reasoningContent[key] = (reasoningContent[key] || '') + reasoning + if (is_final) + responseItem.reasoningFinished = true + }) + }, + async onCompleted(hasError?: boolean) { const { workflowRunningData } = workflowStore.getState() handleResponding(false) diff --git a/web/features/deployments/create-release/ui/dialog.tsx b/web/features/deployments/create-release/ui/dialog.tsx index a0b6dde2fc6..9dd2731af6f 100644 --- a/web/features/deployments/create-release/ui/dialog.tsx +++ b/web/features/deployments/create-release/ui/dialog.tsx @@ -43,9 +43,11 @@ function CreateReleaseCloseButton() { export function CreateReleaseDialogContent() { return ( - - - + + + + + ) } @@ -75,7 +77,7 @@ function CreateReleaseDialogSurface() { } return ( - + <>
-
+ ) } diff --git a/web/features/deployments/detail/__tests__/state.spec.ts b/web/features/deployments/detail/__tests__/state.spec.ts index 5d7b9d47948..1a53b843c4c 100644 --- a/web/features/deployments/detail/__tests__/state.spec.ts +++ b/web/features/deployments/detail/__tests__/state.spec.ts @@ -2,7 +2,7 @@ import type { Getter } from 'jotai' import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' -import { deploymentRouteAppInstanceIdAtom } from '../../route-state' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' type QueryOptions = { enabled?: boolean @@ -65,6 +65,13 @@ async function loadState() { return await import('../state') } +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/overview`, + params: { appInstanceId }, + }) +} + describe('deployment detail state', () => { it('should disable detail queries with skipToken until a route app instance exists', async () => { const state = await loadState() @@ -92,7 +99,7 @@ describe('deployment detail state', () => { const state = await loadState() const store = createStore() - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) store.set(state.deploymentSourceAppIdAtom, 'source-app-1') expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({ diff --git a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts index 7feae92226a..2e58fb3bb80 100644 --- a/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts +++ b/web/features/deployments/detail/versions-tab/__tests__/state.spec.ts @@ -2,7 +2,7 @@ import type { Getter } from 'jotai' import { skipToken } from '@tanstack/react-query' import { atom, createStore } from 'jotai' import { describe, expect, it, vi } from 'vitest' -import { deploymentRouteAppInstanceIdAtom } from '../../../route-state' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' type QueryOptions = { enabled?: boolean @@ -57,6 +57,13 @@ async function loadState() { return await import('../state') } +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/overview`, + params: { appInstanceId }, + }) +} + describe('versions tab state', () => { it('should gate release history and menu queries until route and menu state are ready', async () => { const state = await loadState() @@ -79,7 +86,7 @@ describe('versions tab state', () => { it('should build release history input from the current page', async () => { const state = await loadState() const store = createStore() - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) store.set(state.setReleaseHistoryCurrentPageAtom, -1) expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(0) @@ -99,7 +106,7 @@ describe('versions tab state', () => { it('should scope deploy menu queries to the open release id', async () => { const state = await loadState() const store = createStore() - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) store.set(state.setDeployReleaseMenuOpenAtom, { releaseId: 'release-1', diff --git a/web/features/deployments/list/index.tsx b/web/features/deployments/list/index.tsx index af44215fe14..05eef13a39c 100644 --- a/web/features/deployments/list/index.tsx +++ b/web/features/deployments/list/index.tsx @@ -1,37 +1,8 @@ 'use client' -import type { ReactNode } from 'react' -import { ScopeProvider } from 'jotai-scope' -import { useQueryState } from 'nuqs' -import { - deploymentsListEnvironmentIdAtom, - deploymentsListKeywordsAtom, - envFilterQueryState, - keywordsQueryState, -} from './state' +import { DeploymentsListStateBoundary } from './state' import { DeploymentsListShell } from './ui/shell' -function DeploymentsListStateBoundary({ children }: { - children: ReactNode -}) { - const [envFilter] = useQueryState('env', envFilterQueryState) - const [keywords] = useQueryState('keywords', keywordsQueryState) - const stateKey = `${envFilter ?? 'all'}:${keywords}` - - return ( - - {children} - - ) -} - export function DeploymentsList() { return ( diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts index 0b8b91deae7..fd8bf674aa7 100644 --- a/web/features/deployments/list/state/index.ts +++ b/web/features/deployments/list/state/index.ts @@ -2,10 +2,12 @@ import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen' import type { InfiniteData, QueryKey } from '@tanstack/react-query' +import type { ReactNode } from 'react' import { keepPreviousData } from '@tanstack/react-query' import { atom } from 'jotai' import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' -import { parseAsString } from 'nuqs' +import { useHydrateAtoms } from 'jotai/utils' +import { parseAsString, useQueryState } from 'nuqs' import { consoleQuery } from '@/service/client' import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../../shared/domain/pagination' import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status' @@ -13,8 +15,24 @@ import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-sta export const envFilterQueryState = parseAsString.withOptions({ history: 'push' }) export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' }) -export const deploymentsListKeywordsAtom = atom('') -export const deploymentsListEnvironmentIdAtom = atom(null) +// Mirrors nuqs URL state. DeploymentsListStateBoundary force-hydrates these +// atoms on render so query atoms can read URL filters through Jotai. +const deploymentsListKeywordsAtom = atom('') +const deploymentsListEnvironmentIdAtom = atom(null) + +export function DeploymentsListStateBoundary({ children }: { + children: ReactNode +}) { + const [envFilter] = useQueryState('env', envFilterQueryState) + const [keywords] = useQueryState('keywords', keywordsQueryState) + + useHydrateAtoms([ + [deploymentsListEnvironmentIdAtom, envFilter], + [deploymentsListKeywordsAtom, keywords], + ] as const, { dangerouslyForceHydrate: true }) + + return children +} function listDeploymentStatusPollingInterval(data?: InfiniteData) { const rows = data?.pages?.flatMap(page => diff --git a/web/features/deployments/nav/__tests__/state.spec.ts b/web/features/deployments/nav/__tests__/state.spec.ts index 7374aa7515c..e4eb25bae01 100644 --- a/web/features/deployments/nav/__tests__/state.spec.ts +++ b/web/features/deployments/nav/__tests__/state.spec.ts @@ -6,10 +6,7 @@ import type { import type { Getter } from 'jotai' import { atom, createStore } from 'jotai' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - deploymentRouteAppInstanceIdAtom, - deploymentsRouteActiveAtom, -} from '../../route-state' +import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms' type QueryOptions = { enabled?: boolean @@ -120,6 +117,13 @@ function setListAppInstances(appInstances: AppInstance[]) { }) } +function setDeploymentRoute(store: ReturnType, appInstanceId = 'app-instance-1') { + store.set(setNextRouteStateAtom, { + pathname: `/deployments/${appInstanceId}/overview`, + params: { appInstanceId }, + }) +} + describe('deployments nav state', () => { beforeEach(() => { vi.clearAllMocks() @@ -137,8 +141,7 @@ describe('deployments nav state', () => { it('should append the current route item when it is missing from the list query', async () => { const state = await loadState() const store = createStore() - store.set(deploymentsRouteActiveAtom, true) - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) setListAppInstances([ appInstance({ id: 'app-instance-2', @@ -172,8 +175,7 @@ describe('deployments nav state', () => { it('should use the route id as a fallback current item name', async () => { const state = await loadState() const store = createStore() - store.set(deploymentsRouteActiveAtom, true) - store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1') + setDeploymentRoute(store) setListAppInstances([]) expect(store.get(state.deploymentsNavItemsAtom)).toMatchObject([ diff --git a/web/features/deployments/route-state-hydrator.tsx b/web/features/deployments/route-state-hydrator.tsx deleted file mode 100644 index e2c6fefd44f..00000000000 --- a/web/features/deployments/route-state-hydrator.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'use client' - -import { useSetAtom } from 'jotai' -import { useHydrateAtoms } from 'jotai/react/utils' -import { useEffect } from 'react' -import { useParams } from '@/next/navigation' -import { - deploymentRouteAppInstanceIdAtom, - deploymentsRouteActiveAtom, -} from './route-state' - -function routeAppInstanceId(params?: { appInstanceId?: string | string[] }) { - return typeof params?.appInstanceId === 'string' ? params.appInstanceId : undefined -} - -export function DeploymentsRouteStateHydrator() { - const params = useParams<{ appInstanceId?: string | string[] }>() - const appInstanceId = routeAppInstanceId(params) - const setDeploymentsRouteActive = useSetAtom(deploymentsRouteActiveAtom) - const setRouteAppInstanceId = useSetAtom(deploymentRouteAppInstanceIdAtom) - - useHydrateAtoms([ - [deploymentsRouteActiveAtom, true], - [deploymentRouteAppInstanceIdAtom, appInstanceId], - ] as const, { dangerouslyForceHydrate: true }) - - useEffect(() => { - return () => { - setDeploymentsRouteActive(false) - setRouteAppInstanceId(undefined) - } - }, [setDeploymentsRouteActive, setRouteAppInstanceId]) - - return null -} diff --git a/web/features/deployments/route-state.ts b/web/features/deployments/route-state.ts index 8e1d7d5fead..fc2c7397188 100644 --- a/web/features/deployments/route-state.ts +++ b/web/features/deployments/route-state.ts @@ -1,6 +1,19 @@ 'use client' import { atom } from 'jotai' +import { + nextParamsAtom, + nextPathnameAtom, +} from '@/app/components/next-route-state/atoms' -export const deploymentsRouteActiveAtom = atom(false) -export const deploymentRouteAppInstanceIdAtom = atom(undefined) +function isDeploymentsRoute(pathname: string) { + return pathname === '/deployments' || pathname.startsWith('/deployments/') +} + +export const deploymentsRouteActiveAtom = atom(get => isDeploymentsRoute(get(nextPathnameAtom))) + +export const deploymentRouteAppInstanceIdAtom = atom((get) => { + const appInstanceId = get(nextParamsAtom).appInstanceId + + return typeof appInstanceId === 'string' ? appInstanceId : undefined +}) diff --git a/web/i18n/ar-TN/agent-v-2.json b/web/i18n/ar-TN/agent-v-2.json index b04fb73713e..cd8d3ccd7aa 100644 --- a/web/i18n/ar-TN/agent-v-2.json +++ b/web/i18n/ar-TN/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "حذف {{name}}؟", "roster.deleteFailed": "فشل حذف الوكيل.", "roster.deleteSuccess": "تم حذف الوكيل.", + "roster.duplicateDialog.description": "أنشئ نسخة من {{name}} وخصّص هويته في Roster.", + "roster.duplicateDialog.title": "نسخ الوكيل", + "roster.duplicateForm.changeIcon": "تغيير أيقونة نسخة {{name}}", "roster.duplicateSuccess": "تم نسخ الوكيل.", "roster.editAgent": "تعديل {{name}}", "roster.editDialog.description": "حدّث اسم Roster والوصف والدور لهذا الوكيل.", diff --git a/web/i18n/de-DE/agent-v-2.json b/web/i18n/de-DE/agent-v-2.json index 6ee022b5be2..51f1628d7b0 100644 --- a/web/i18n/de-DE/agent-v-2.json +++ b/web/i18n/de-DE/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "{{name}} löschen?", "roster.deleteFailed": "Agent konnte nicht gelöscht werden.", "roster.deleteSuccess": "Agent gelöscht.", + "roster.duplicateDialog.description": "Erstellen Sie eine Kopie von {{name}} und passen Sie ihre Roster-Identität an.", + "roster.duplicateDialog.title": "Agent duplizieren", + "roster.duplicateForm.changeIcon": "Symbol des Duplikats für {{name}} ändern", "roster.duplicateSuccess": "Agent dupliziert.", "roster.editAgent": "{{name}} bearbeiten", "roster.editDialog.description": "Aktualisieren Sie Roster-Name, Beschreibung und Rolle dieses Agenten.", diff --git a/web/i18n/es-ES/agent-v-2.json b/web/i18n/es-ES/agent-v-2.json index 80ef2d704ab..8d6c991bd8e 100644 --- a/web/i18n/es-ES/agent-v-2.json +++ b/web/i18n/es-ES/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "¿Eliminar {{name}}?", "roster.deleteFailed": "Error al eliminar el agente.", "roster.deleteSuccess": "Agente eliminado.", + "roster.duplicateDialog.description": "Crea una copia de {{name}} y personaliza su identidad en el roster.", + "roster.duplicateDialog.title": "Duplicar agente", + "roster.duplicateForm.changeIcon": "Cambiar icono del duplicado de {{name}}", "roster.duplicateSuccess": "Agente duplicado.", "roster.editAgent": "Editar {{name}}", "roster.editDialog.description": "Actualiza el nombre, la descripción y el rol del roster para este agente.", diff --git a/web/i18n/fa-IR/agent-v-2.json b/web/i18n/fa-IR/agent-v-2.json index 49abbfd93aa..c556885587e 100644 --- a/web/i18n/fa-IR/agent-v-2.json +++ b/web/i18n/fa-IR/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "حذف {{name}}؟", "roster.deleteFailed": "حذف عامل ناموفق بود.", "roster.deleteSuccess": "عامل حذف شد.", + "roster.duplicateDialog.description": "یک کپی از {{name}} ایجاد کنید و هویت آن در Roster را سفارشی کنید.", + "roster.duplicateDialog.title": "تکثیر عامل", + "roster.duplicateForm.changeIcon": "تغییر آیکون کپی برای {{name}}", "roster.duplicateSuccess": "عامل تکثیر شد.", "roster.editAgent": "ویرایش {{name}}", "roster.editDialog.description": "نام، توضیحات و نقش Roster این عامل را به‌روزرسانی کنید.", diff --git a/web/i18n/fr-FR/agent-v-2.json b/web/i18n/fr-FR/agent-v-2.json index 2992e7e4e99..aa79a2c6d91 100644 --- a/web/i18n/fr-FR/agent-v-2.json +++ b/web/i18n/fr-FR/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Supprimer {{name}} ?", "roster.deleteFailed": "Échec de la suppression de l’agent.", "roster.deleteSuccess": "Agent supprimé.", + "roster.duplicateDialog.description": "Créez une copie de {{name}} et personnalisez son identité dans le roster.", + "roster.duplicateDialog.title": "Dupliquer l’agent", + "roster.duplicateForm.changeIcon": "Changer l’icône du duplicata pour {{name}}", "roster.duplicateSuccess": "Agent dupliqué.", "roster.editAgent": "Modifier {{name}}", "roster.editDialog.description": "Mettez à jour le nom, la description et le rôle de cet agent dans le roster.", diff --git a/web/i18n/hi-IN/agent-v-2.json b/web/i18n/hi-IN/agent-v-2.json index 0c8b4b9b7a6..574065d1385 100644 --- a/web/i18n/hi-IN/agent-v-2.json +++ b/web/i18n/hi-IN/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "{{name}} हटाएँ?", "roster.deleteFailed": "एजेंट हटाने में विफल।", "roster.deleteSuccess": "एजेंट हटा दिया गया।", + "roster.duplicateDialog.description": "{{name}} की एक प्रति बनाएँ और उसकी Roster पहचान को अनुकूलित करें।", + "roster.duplicateDialog.title": "एजेंट डुप्लिकेट करें", + "roster.duplicateForm.changeIcon": "{{name}} के लिए डुप्लिकेट आइकन बदलें", "roster.duplicateSuccess": "एजेंट डुप्लिकेट किया गया।", "roster.editAgent": "{{name}} संपादित करें", "roster.editDialog.description": "इस एजेंट का Roster नाम, विवरण और भूमिका अपडेट करें।", diff --git a/web/i18n/id-ID/agent-v-2.json b/web/i18n/id-ID/agent-v-2.json index 995b41bb20c..61b99927cc5 100644 --- a/web/i18n/id-ID/agent-v-2.json +++ b/web/i18n/id-ID/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Hapus {{name}}?", "roster.deleteFailed": "Gagal menghapus agen.", "roster.deleteSuccess": "Agen dihapus.", + "roster.duplicateDialog.description": "Buat salinan {{name}} dan sesuaikan identitas Roster-nya.", + "roster.duplicateDialog.title": "Duplikat agen", + "roster.duplicateForm.changeIcon": "Ubah ikon duplikat untuk {{name}}", "roster.duplicateSuccess": "Agen diduplikasi.", "roster.editAgent": "Edit {{name}}", "roster.editDialog.description": "Perbarui nama, deskripsi, dan peran Roster untuk agen ini.", diff --git a/web/i18n/it-IT/agent-v-2.json b/web/i18n/it-IT/agent-v-2.json index 13d14580ec7..e0519649b29 100644 --- a/web/i18n/it-IT/agent-v-2.json +++ b/web/i18n/it-IT/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Eliminare {{name}}?", "roster.deleteFailed": "Impossibile eliminare l’agente.", "roster.deleteSuccess": "Agente eliminato.", + "roster.duplicateDialog.description": "Crea una copia di {{name}} e personalizza la sua identità nel roster.", + "roster.duplicateDialog.title": "Duplica agente", + "roster.duplicateForm.changeIcon": "Cambia icona del duplicato per {{name}}", "roster.duplicateSuccess": "Agente duplicato.", "roster.editAgent": "Modifica {{name}}", "roster.editDialog.description": "Aggiorna il nome, la descrizione e il ruolo del roster per questo agente.", diff --git a/web/i18n/ja-JP/agent-v-2.json b/web/i18n/ja-JP/agent-v-2.json index 0cbe981a120..c572609eee1 100644 --- a/web/i18n/ja-JP/agent-v-2.json +++ b/web/i18n/ja-JP/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "{{name}} を削除しますか?", "roster.deleteFailed": "エージェントの削除に失敗しました。", "roster.deleteSuccess": "エージェントを削除しました。", + "roster.duplicateDialog.description": "{{name}} のコピーを作成し、その Roster での識別情報をカスタマイズします。", + "roster.duplicateDialog.title": "エージェントを複製", + "roster.duplicateForm.changeIcon": "{{name}} の複製アイコンを変更", "roster.duplicateSuccess": "エージェントを複製しました。", "roster.editAgent": "{{name}} を編集", "roster.editDialog.description": "このエージェントの Roster 名、説明、ロールを更新します。", diff --git a/web/i18n/ko-KR/agent-v-2.json b/web/i18n/ko-KR/agent-v-2.json index 1092b0c3f47..24a0b44c404 100644 --- a/web/i18n/ko-KR/agent-v-2.json +++ b/web/i18n/ko-KR/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "{{name}}을(를) 삭제하시겠습니까?", "roster.deleteFailed": "에이전트 삭제에 실패했습니다.", "roster.deleteSuccess": "에이전트가 삭제되었습니다.", + "roster.duplicateDialog.description": "{{name}} 의 사본을 만들고 Roster 신원을 사용자 지정합니다.", + "roster.duplicateDialog.title": "에이전트 복제", + "roster.duplicateForm.changeIcon": "{{name}} 의 복제본 아이콘 변경", "roster.duplicateSuccess": "에이전트가 복제되었습니다.", "roster.editAgent": "{{name}} 편집", "roster.editDialog.description": "이 에이전트의 Roster 이름, 설명 및 역할을 업데이트합니다.", diff --git a/web/i18n/nl-NL/agent-v-2.json b/web/i18n/nl-NL/agent-v-2.json index ac2c94e7007..19c9aa3afd9 100644 --- a/web/i18n/nl-NL/agent-v-2.json +++ b/web/i18n/nl-NL/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "{{name}} verwijderen?", "roster.deleteFailed": "Verwijderen van agent mislukt.", "roster.deleteSuccess": "Agent verwijderd.", + "roster.duplicateDialog.description": "Maak een kopie van {{name}} en pas de roster-identiteit aan.", + "roster.duplicateDialog.title": "Agent dupliceren", + "roster.duplicateForm.changeIcon": "Duplicaatpictogram voor {{name}} wijzigen", "roster.duplicateSuccess": "Agent gedupliceerd.", "roster.editAgent": "{{name}} bewerken", "roster.editDialog.description": "Werk de rosternaam, beschrijving en rol van deze agent bij.", diff --git a/web/i18n/pl-PL/agent-v-2.json b/web/i18n/pl-PL/agent-v-2.json index 1bdcfe0ced8..1d31614b0c1 100644 --- a/web/i18n/pl-PL/agent-v-2.json +++ b/web/i18n/pl-PL/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Usunąć {{name}}?", "roster.deleteFailed": "Nie udało się usunąć agenta.", "roster.deleteSuccess": "Agent usunięty.", + "roster.duplicateDialog.description": "Utwórz kopię {{name}} i dostosuj jego tożsamość w Roster.", + "roster.duplicateDialog.title": "Duplikuj agenta", + "roster.duplicateForm.changeIcon": "Zmień ikonę duplikatu dla {{name}}", "roster.duplicateSuccess": "Agent zduplikowany.", "roster.editAgent": "Edytuj {{name}}", "roster.editDialog.description": "Zaktualizuj nazwę w Roster, opis i rolę tego agenta.", diff --git a/web/i18n/pt-BR/agent-v-2.json b/web/i18n/pt-BR/agent-v-2.json index 254f30ee874..ee0d1f9d38e 100644 --- a/web/i18n/pt-BR/agent-v-2.json +++ b/web/i18n/pt-BR/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Excluir {{name}}?", "roster.deleteFailed": "Falha ao excluir o agente.", "roster.deleteSuccess": "Agente excluído.", + "roster.duplicateDialog.description": "Crie uma cópia de {{name}} e personalize sua identidade no roster.", + "roster.duplicateDialog.title": "Duplicar agente", + "roster.duplicateForm.changeIcon": "Alterar ícone da duplicata de {{name}}", "roster.duplicateSuccess": "Agente duplicado.", "roster.editAgent": "Editar {{name}}", "roster.editDialog.description": "Atualize o nome, a descrição e a função deste agente no roster.", diff --git a/web/i18n/ro-RO/agent-v-2.json b/web/i18n/ro-RO/agent-v-2.json index d454f784f05..9f10bf9b286 100644 --- a/web/i18n/ro-RO/agent-v-2.json +++ b/web/i18n/ro-RO/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Ștergeți {{name}}?", "roster.deleteFailed": "Ștergerea agentului a eșuat.", "roster.deleteSuccess": "Agent șters.", + "roster.duplicateDialog.description": "Creați o copie a {{name}} și personalizați identitatea sa în roster.", + "roster.duplicateDialog.title": "Duplică agentul", + "roster.duplicateForm.changeIcon": "Schimbă pictograma duplicatului pentru {{name}}", "roster.duplicateSuccess": "Agent duplicat.", "roster.editAgent": "Editați {{name}}", "roster.editDialog.description": "Actualizați numele, descrierea și rolul din roster pentru acest agent.", diff --git a/web/i18n/ru-RU/agent-v-2.json b/web/i18n/ru-RU/agent-v-2.json index 8358012dfd2..6830bd58e1b 100644 --- a/web/i18n/ru-RU/agent-v-2.json +++ b/web/i18n/ru-RU/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Удалить {{name}}?", "roster.deleteFailed": "Не удалось удалить агента.", "roster.deleteSuccess": "Агент удалён.", + "roster.duplicateDialog.description": "Создайте копию {{name}} и настройте его идентичность в Roster.", + "roster.duplicateDialog.title": "Дублировать агента", + "roster.duplicateForm.changeIcon": "Сменить иконку копии для {{name}}", "roster.duplicateSuccess": "Агент продублирован.", "roster.editAgent": "Редактировать {{name}}", "roster.editDialog.description": "Обновите имя в Roster, описание и роль этого агента.", diff --git a/web/i18n/sl-SI/agent-v-2.json b/web/i18n/sl-SI/agent-v-2.json index 5b1144ba449..9642ceba1f4 100644 --- a/web/i18n/sl-SI/agent-v-2.json +++ b/web/i18n/sl-SI/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Izbrišem {{name}}?", "roster.deleteFailed": "Agenta ni bilo mogoče izbrisati.", "roster.deleteSuccess": "Agent izbrisan.", + "roster.duplicateDialog.description": "Ustvarite kopijo agenta {{name}} in prilagodite njegovo identiteto v Roster.", + "roster.duplicateDialog.title": "Podvoji agenta", + "roster.duplicateForm.changeIcon": "Spremeni ikono kopije za {{name}}", "roster.duplicateSuccess": "Agent podvojen.", "roster.editAgent": "Uredi {{name}}", "roster.editDialog.description": "Posodobite ime v Roster, opis in vlogo za tega agenta.", diff --git a/web/i18n/th-TH/agent-v-2.json b/web/i18n/th-TH/agent-v-2.json index eab67259bdf..60a9f8a47ae 100644 --- a/web/i18n/th-TH/agent-v-2.json +++ b/web/i18n/th-TH/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "ลบ {{name}} หรือไม่", "roster.deleteFailed": "ลบตัวแทนไม่สำเร็จ", "roster.deleteSuccess": "ลบตัวแทนแล้ว", + "roster.duplicateDialog.description": "สร้างสำเนาของ {{name}} และปรับแต่งข้อมูลระบุตัวตนใน Roster", + "roster.duplicateDialog.title": "ทำสำเนาตัวแทน", + "roster.duplicateForm.changeIcon": "เปลี่ยนไอคอนสำเนาสำหรับ {{name}}", "roster.duplicateSuccess": "ทำสำเนาตัวแทนแล้ว", "roster.editAgent": "แก้ไข {{name}}", "roster.editDialog.description": "อัปเดตชื่อ คำอธิบาย และบทบาทใน Roster สำหรับตัวแทนนี้", diff --git a/web/i18n/tr-TR/agent-v-2.json b/web/i18n/tr-TR/agent-v-2.json index bf65737ac30..6d7fc8c4bae 100644 --- a/web/i18n/tr-TR/agent-v-2.json +++ b/web/i18n/tr-TR/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "{{name}} silinsin mi?", "roster.deleteFailed": "Ajan silinemedi.", "roster.deleteSuccess": "Ajan silindi.", + "roster.duplicateDialog.description": "{{name}} ajanının bir kopyasını oluşturun ve Roster kimliğini özelleştirin.", + "roster.duplicateDialog.title": "Ajanı çoğalt", + "roster.duplicateForm.changeIcon": "{{name}} için kopya simgesini değiştir", "roster.duplicateSuccess": "Ajan çoğaltıldı.", "roster.editAgent": "{{name}} düzenle", "roster.editDialog.description": "Bu ajan için Roster adı, açıklaması ve rolünü güncelleyin.", diff --git a/web/i18n/uk-UA/agent-v-2.json b/web/i18n/uk-UA/agent-v-2.json index 3dec271ef27..43f7c98ed82 100644 --- a/web/i18n/uk-UA/agent-v-2.json +++ b/web/i18n/uk-UA/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Видалити {{name}}?", "roster.deleteFailed": "Не вдалося видалити агента.", "roster.deleteSuccess": "Агента видалено.", + "roster.duplicateDialog.description": "Створіть копію {{name}} та налаштуйте його ідентичність у Roster.", + "roster.duplicateDialog.title": "Дублювати агента", + "roster.duplicateForm.changeIcon": "Змінити іконку копії для {{name}}", "roster.duplicateSuccess": "Агента продубльовано.", "roster.editAgent": "Редагувати {{name}}", "roster.editDialog.description": "Оновіть ім'я в Roster, опис і роль для цього агента.", diff --git a/web/i18n/vi-VN/agent-v-2.json b/web/i18n/vi-VN/agent-v-2.json index 35d167ea843..2a09a98c212 100644 --- a/web/i18n/vi-VN/agent-v-2.json +++ b/web/i18n/vi-VN/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "Xóa {{name}}?", "roster.deleteFailed": "Xóa tác nhân thất bại.", "roster.deleteSuccess": "Đã xóa tác nhân.", + "roster.duplicateDialog.description": "Tạo một bản sao của {{name}} và tùy chỉnh danh tính của nó trong Roster.", + "roster.duplicateDialog.title": "Nhân bản tác nhân", + "roster.duplicateForm.changeIcon": "Đổi biểu tượng bản sao cho {{name}}", "roster.duplicateSuccess": "Đã nhân bản tác nhân.", "roster.editAgent": "Chỉnh sửa {{name}}", "roster.editDialog.description": "Cập nhật tên, mô tả và vai trò Roster cho tác nhân này.", diff --git a/web/i18n/zh-Hant/agent-v-2.json b/web/i18n/zh-Hant/agent-v-2.json index 74ff07b4a05..92e81406751 100644 --- a/web/i18n/zh-Hant/agent-v-2.json +++ b/web/i18n/zh-Hant/agent-v-2.json @@ -353,6 +353,9 @@ "roster.deleteDialog.title": "刪除 {{name}}?", "roster.deleteFailed": "智能體刪除失敗。", "roster.deleteSuccess": "智能體已刪除。", + "roster.duplicateDialog.description": "建立 {{name}} 的副本,並自訂它在 Roster 中的身份資訊。", + "roster.duplicateDialog.title": "複製智能體", + "roster.duplicateForm.changeIcon": "更換 {{name}} 副本的圖示", "roster.duplicateSuccess": "智能體已複製。", "roster.editAgent": "編輯 {{name}}", "roster.editDialog.description": "更新此智能體在 Roster 中的名稱、描述和角色。", diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts index d6ed242ed91..953e848fc4d 100644 --- a/web/service/base.spec.ts +++ b/web/service/base.spec.ts @@ -217,6 +217,57 @@ describe('handleStream', () => { expect(onCompleted).toHaveBeenCalled() }) + it('should dispatch reasoning_chunk events to onReasoning', async () => { + // Arrange + const onData = vi.fn() + const onCompleted = vi.fn() + const onReasoning = vi.fn() + + const reasoningEvent = { + event: 'reasoning_chunk', + task_id: 'task-1', + data: { message_id: 'm-1', reasoning: 'let me think', node_id: 'llm', is_final: false }, + } + + const mockReader = { + read: vi.fn() + .mockResolvedValueOnce({ + done: false, + value: new TextEncoder().encode(`data: ${JSON.stringify(reasoningEvent)}\n`), + }) + .mockResolvedValueOnce({ + done: true, + value: undefined, + }), + } + + const mockResponse = { + ok: true, + body: { + getReader: () => mockReader, + }, + } as unknown as Response + + // onReasoning is the last positional handler; fill the unused intervening slots. + const interveningNoops = Array.from({ length: 29 }, () => undefined) + + // Act + ;(handleStream as (...args: unknown[]) => void)( + mockResponse, + onData, + onCompleted, + ...interveningNoops, + onReasoning, + ) + + // Wait for the stream to be processed + await new Promise(resolve => setTimeout(resolve, 50)) + + // Assert - the full event object is forwarded to onReasoning, answer stays untouched + expect(onReasoning).toHaveBeenCalledWith(reasoningEvent) + expect(onData).not.toHaveBeenCalled() + }) + it('should throw error when response is not ok', () => { // Arrange const onData = vi.fn() diff --git a/web/service/base.ts b/web/service/base.ts index 9dd15d66ea4..41c99ba50f8 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -21,6 +21,7 @@ import type { NodeStartedResponse, ParallelBranchFinishedResponse, ParallelBranchStartedResponse, + ReasoningChunkResponse, TextChunkResponse, TextReplaceResponse, WorkflowFinishedResponse, @@ -66,6 +67,7 @@ type IOnIterationFinished = (workflowFinished: IterationFinishedResponse) => voi type IOnParallelBranchStarted = (parallelBranchStarted: ParallelBranchStartedResponse) => void type IOnParallelBranchFinished = (parallelBranchFinished: ParallelBranchFinishedResponse) => void type IOnTextChunk = (textChunk: TextChunkResponse) => void +type IOnReasoning = (reasoningChunk: ReasoningChunkResponse) => void type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void type IOnTextReplace = (textReplace: TextReplaceResponse) => void @@ -95,6 +97,7 @@ export type IOtherOptions = { request?: Request onData?: IOnData // for stream + onReasoning?: IOnReasoning onThought?: IOnThought onFile?: IOnFile onMessageEnd?: IOnMessageEnd @@ -223,6 +226,7 @@ export const handleStream = ( onDataSourceNodeProcessing?: IOnDataSourceNodeProcessing, onDataSourceNodeCompleted?: IOnDataSourceNodeCompleted, onDataSourceNodeError?: IOnDataSourceNodeError, + onReasoning?: IOnReasoning, ) => { if (!response.ok) throw new Error('Network response was not ok') @@ -340,6 +344,9 @@ export const handleStream = ( else if (bufferObj.event === 'text_chunk') { onTextChunk?.(bufferObj as TextChunkResponse) } + else if (bufferObj.event === 'reasoning_chunk') { + onReasoning?.(bufferObj as ReasoningChunkResponse) + } else if (bufferObj.event === 'text_replace') { onTextReplace?.(bufferObj as TextReplaceResponse) } @@ -461,6 +468,7 @@ export const ssePost = async ( const { isPublicAPI = false, onData, + onReasoning, onCompleted, onThought, onFile, @@ -599,6 +607,7 @@ export const ssePost = async ( onDataSourceNodeProcessing, onDataSourceNodeCompleted, onDataSourceNodeError, + onReasoning, ) }) .catch((e) => { @@ -616,6 +625,7 @@ export const sseGet = async ( const { isPublicAPI = false, onData, + onReasoning, onCompleted, onThought, onFile, @@ -747,6 +757,7 @@ export const sseGet = async ( onDataSourceNodeProcessing, onDataSourceNodeCompleted, onDataSourceNodeError, + onReasoning, ) }) .catch((e) => { diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 7cc9edf8bd0..cfc886158eb 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -309,6 +309,17 @@ export type TextChunkResponse = { } } +export type ReasoningChunkResponse = { + task_id: string + event: string + data: { + message_id: string + reasoning: string + node_id?: string + is_final?: boolean + } +} + export type TextReplaceResponse = { task_id: string workflow_run_id: string